Setting the file. One moment.
Metering Core · AWS Marketplace Metering · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page Query Patterns
70
Creating Amazon Aurora Db Cluster With Instances
93
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Creating API Gateway Stage
257
def get_pending_groups_for_license
— line 257
This file
Number 61.22
Position 22 of 30
Type Python
Size 38 KB
Lines 830 scripts/ metering_core.py
Python · 830 lines · 38 KB
connected by SQS. ``aggregated_usage`` (PK licenseArn, SK account#dimension#hour) is
15 the submission source of truth, written by the aggregator via a CONDITIONAL PutItem
16 (the idempotency commit point) and consumed by the submitter.
17 """
18
19 import json
20 import logging
21 import math
22 import os
23 import re
24 from datetime import datetime, timedelta, timezone
25 from decimal import Decimal
26 from typing import Any, Dict, List, Optional, Tuple
27
28 import boto3
29 from botocore.exceptions import ClientError
30
31 logger = logging.getLogger()
32 logger.setLevel(logging. INFO )
33
34 # Subscribers table is in us-east-1 (where EventBridge lifecycle events land).
35 _dynamodb_iad = boto3.resource(
36 "dynamodb" , region_name = os.environ.get( "SUBSCRIBERS_TABLE_REGION" , "us-east-1" )
37 )
38 subscribers_table = _dynamodb_iad.Table(os.environ[ "SUBSCRIBERS_TABLE" ])
39
40 # Usage + aggregated-usage tables are in the seller's metering region (this Lambda's
41 # runtime region).
42 _dynamodb = boto3.resource( "dynamodb" )
43 # USAGE_TABLE is set on the raw-pipeline functions (discoverer/aggregator/cleanup). In
44 # direct-submit mode there is no raw usage table, so the submitter/expiry (which import this
45 # module) run without it — tolerate its absence rather than failing at import.
46 _USAGE_TABLE_NAME = os.environ.get( "USAGE_TABLE" )
47 # Typed Any (not Optional[Table]) so mypy check_untyped_defs does not flag .query on the
48 # raw-pipeline read helpers below: those helpers only run on the discoverer/aggregator/
49 # cleanup Lambdas, where USAGE_TABLE is always set. No behavior change.
50 usage_table: Any = _dynamodb.Table( _USAGE_TABLE_NAME ) if _USAGE_TABLE_NAME else None
51 # AGGREGATED_USAGE_TABLE is set on the aggregator/submitter/cleanup functions; the
52 # discoverer does not need it, so tolerate its absence there.
53 _AGG_TABLE_NAME = os.environ.get( "AGGREGATED_USAGE_TABLE" )
54 aggregated_usage_table = _dynamodb.Table( _AGG_TABLE_NAME ) if _AGG_TABLE_NAME else None
55
56 # BatchMeterUsage is a REGIONAL API: no hardcoded region so it runs in the Lambda's region.
57 mp_client = boto3.client( "meteringmarketplace" )
58
59 PRODUCT_CODE = os.environ[ "PRODUCT_CODE" ]
60
61 BATCH_SIZE = 25 # BatchMeterUsage hard limit: max 25 UsageRecords per call.
62 MAX_ALLOCATIONS_PER_RECORD = 2500 # BatchMeterUsage per-UsageRecord UsageAllocations cap.
63 MAX_TAGS_PER_ALLOCATION = 5 # BatchMeterUsage per-allocation Tags cap.
64 CLEANUP_KEYS_PER_MESSAGE = 100 # row keys per cleanup SQS message.
65
66 _LICENSE_ARN_RE = re.compile( r " ^ arn:aws [ a-zA-Z- ] * :license-manager: [ ^: ] * : [ 0-9 ] {12} :license: . + $ " )
67
68 METRIC_NAMESPACE = os.environ.get( "METRIC_NAMESPACE" , "AwsMarketplace/Metering" )
69 # live (prod) vs dry-run (non-prod sandbox). Emitted as an EMF dimension so a dry run is never
70 # mistaken for real billing on the dashboards/alarms.
71 METERING_MODE = os.environ.get( "METERING_MODE" , "live" ).strip().lower()
72
73 STATUS_REJECTED = "RejectedClientSide"
74
75 # Max recursion depth for request-level-failure bisection. Bisecting isolates a poison record so
76 # its co-batched records still meter, but UNBOUNDED bisection can issue up to ~2N BatchMeterUsage
77 # calls for an N-record batch when many records fail — a real load/throttle risk on the submitter.
78 # The cap is DERIVED from BATCH_SIZE as ceil(log2(BATCH_SIZE)) (= 5 for 25) so the recursion can
79 # always narrow a full batch down to a SINGLE record — a shallower cap (e.g. 3) leaves the
80 # smallest reachable sub-batch at ~4 records and can NEVER isolate a lone poison record, defeating
81 # the purpose. At the cap a still-failing MULTI-record sub-batch is NOT split further — it is left
82 # pending (no terminal outcome) so it is retried on the NEXT run and a BisectDepthExceeded metric
83 # is emitted; a single isolated record is still terminally reason-coded.
84 MAX_BISECT_DEPTH = math.ceil(math.log2( BATCH_SIZE )) if BATCH_SIZE > 1 else 0
85
86 # Sparse deprovisioning index (subscribers table): the subscription Lambda sets
87 # `deprovisioningPendingFlag = DEPROVISIONING_PENDING_VALUE` (constant HASH) + a
88 # `deprovisioningExpiry` (event time + ~1h, ISO) on License Deprovisioned; the events-stack
89 # cleanup Lambda REMOVEs both once expired. The index is HASH=flag + RANGE=expiry, so a
90 # single Query returns every license still in a flush window (discoverer sweep + submitter),
91 # and a range Query (expiry <= now) returns the expired ones (cleanup).
92 DEPROVISIONING_PENDING_INDEX = "deprovisioning-pending-index"
93 DEPROVISIONING_PENDING_VALUE = "1"
94
95 # Hour-bucket format (GSI HASH) and second-precision timestamp format (sort key).
96 HOUR_FMT = "%Y-%m- %d T%H"
97 SECOND_FMT = "%Y-%m- %d T%H:%M:%S" # raw-usage sort-key suffix precision (whole seconds)
98
99
100 def validate_sort_key (sort_key: str , account_id: str , dimension: str ) -> Optional[ str ]:
101 """Validate a raw-usage row's sort key is exactly
102 ``{customerAWSAccountId}#{dimension}#{YYYY-MM-DDTHH:MM:SS}`` (SECOND precision).
103
104 Returns a client-side reject reason code if malformed, else None. This enforces the
105 seller-writer contract at aggregation time (the aggregator's fold) so a wrong-precision
106 (e.g. millisecond) or malformed suffix is caught as a reason-coded RejectedClientSide row
107 rather than silently mis-folded or never metered. The account/dimension segments MUST
108 match the group the row was read for (a mismatch means a corrupt/mis-keyed row).
109 """
110 if not sort_key:
111 return "MalformedSortKey"
112 # rsplit on the last two '#': the timestamp suffix has no '#', but a dimension MAY.
113 parts = sort_key.split( "#" )
114 if len (parts) < 3 :
115 return "MalformedSortKey"
116 key_account = parts[ 0 ]
117 key_ts = parts[ - 1 ]
118 key_dimension = "#" .join(parts[ 1 : - 1 ])
119 if key_account != account_id or key_dimension != dimension:
120 return "SortKeyMismatch"
121 # Exact SECOND precision: strptime accepts it AND re-formatting round-trips (so a
122 # millisecond/fractional or truncated suffix like '...:07.123' or '...T13' is rejected).
123 try :
124 parsed = datetime.strptime(key_ts, SECOND_FMT )
125 except ( ValueError , TypeError ):
126 return "MalformedTimestamp"
127 if parsed.strftime( SECOND_FMT ) != key_ts:
128 return "MalformedTimestamp"
129 return None
130
131
132 # ─── Time windows ────────────────────────────────────────────────────────────
133 def completed_hour_buckets (now: datetime) -> List[ str ]:
134 """Hour-bucket strings for now-23h .. now-1h inclusive, OLDEST first."""
135 base = now.replace( minute = 0 , second = 0 , microsecond = 0 )
136 return [(base - timedelta( hours = h)).strftime( HOUR_FMT ) for h in range ( 23 , 0 , - 1 )]
137
138
139 def hour_bucket_for_offset (now: datetime, hour_offset: int ) -> str :
140 """The single completed hour bucket ``floor(now, hour) - hour_offset``.
141
142 The discoverer derives its bucket purely from the constant target Input offset, never
143 from a second wall-clock read, so 23 offsets map to 23 disjoint, gap-free buckets.
144 """
145 base = now.replace( minute = 0 , second = 0 , microsecond = 0 )
146 return (base - timedelta( hours = hour_offset)).strftime( HOUR_FMT )
147
148
149 # ─── Audit timestamps (createdAt/updatedAt) ────────────────────────────────────
150 def now_iso () -> str :
151 """Current time as an ISO-8601 UTC timestamp (whole seconds, 'Z' suffix)."""
152 return datetime.now(timezone.utc).strftime( "%Y-%m- %d T%H:%M:%SZ" )
153
154
155 def with_audit_timestamps (update_expr: str , values: dict , names: Optional[ dict ] = None ):
156 """Augment an UpdateItem expression with createdAt/updatedAt audit stamps.
157
158 Sets ``createdAt = if_not_exists(createdAt, :now)`` (written ONCE, preserved on upsert)
159 and ``updatedAt = :now`` (refreshed on EVERY write). Works whether ``update_expr`` already
160 has a ``SET`` clause (the stamps are appended to it) or is REMOVE-only (a ``SET`` clause is
161 added). Returns the augmented (expr, values, names). Idempotent stamping of createdAt makes
162 repeated upserts keep the original creation time.
163 """
164 ts = now_iso()
165 values = dict (values)
166 values[ ":now" ] = ts
167 stamps = "createdAt = if_not_exists(createdAt, :now), updatedAt = :now"
168 if "SET " in update_expr:
169 update_expr = f " { update_expr } , { stamps } "
170 else :
171 # REMOVE-only (or other clause) with no SET — add one.
172 update_expr = f " { update_expr } SET { stamps } "
173 return update_expr, values, names
174
175
176 def audit_item (item: dict ) -> dict :
177 """Stamp createdAt + updatedAt on a PutItem payload (both = now for a fresh insert)."""
178 ts = now_iso()
179 item = dict (item)
180 item.setdefault( "createdAt" , ts)
181 item[ "updatedAt" ] = ts
182 return item
183
184
185 # ─── Month-boundary reconciliation window ────────────────────────────
186 # AWS Marketplace accepts PREVIOUS-month usage until this UTC hour on the 1st of the next
187 # month (the documented month-end grace). Named constants so the boundary is adjustable.
188 MONTH_GRACE_LOCK_DROP_HOUR = 3 # from 03:00 UTC on the 1st, previous-month lock -> 0
189 MONTH_GRACE_CUTOFF_HOUR = 6 # service stops accepting previous-month usage at 06:00 UTC
190
191
192 def is_previous_month_bucket (hour_bucket: str , now: datetime) -> bool :
193 """True if the bucket's hour falls in the calendar month BEFORE ``now``'s month (UTC)."""
194 bucket_ts = datetime.strptime(hour_bucket, HOUR_FMT ).replace( tzinfo = timezone.utc)
195 return (bucket_ts.year, bucket_ts.month) < (now.year, now.month)
196
197
198 def _in_month_grace_flush (now: datetime) -> bool :
199 """True during the month-end IMMEDIATE-flush window: the 1st of the month, at/after
200 MONTH_GRACE_LOCK_DROP_HOUR (03:00) and before the MONTH_GRACE_CUTOFF_HOUR (06:00) UTC."""
201 return now.day == 1 and MONTH_GRACE_LOCK_DROP_HOUR <= now.hour < MONTH_GRACE_CUTOFF_HOUR
202
203
204 def effective_lock_hours (hour_bucket: str , now: datetime, configured_lock: int ) -> int :
205 """Month-aware effective lock (in hours) for one bucket.
206
207 - Current-month bucket: the configured lock, always.
208 - Previous-month bucket, from 03:00 UTC on the 1st (until the 06:00 cutoff): 0 (submit
209 immediately every run).
210 - Previous-month bucket, before 03:00 UTC on the 1st (and any other time it is still a
211 previous-month bucket within the window): the configured lock, unchanged.
212 """
213 lock = min ( max (configured_lock, 1 ), 20 )
214 if is_previous_month_bucket(hour_bucket, now) and _in_month_grace_flush(now):
215 return 0
216 return lock
217
218
219 def previous_month_grace_open (now: datetime) -> bool :
220 """True while previous-month age-out SHALL be suppressed: on the 1st of the month
221 before the 06:00 UTC service cutoff. After the cutoff, normal age-out resumes."""
222 return now.day == 1 and now.hour < MONTH_GRACE_CUTOFF_HOUR
223
224
225 # ─── metering_pending GSI discovery ──────────────────────────────────────────
226 def get_pending_groups (hour_bucket: str ) -> List[Tuple[ str , str , str ]]:
227 """Query the ``metering_pending`` GSI for one hour bucket and return de-duplicated
228 ``(licenseArn, customerAWSAccountId, dimension)`` tuples.
229
230 Errors are NOT swallowed: a failed GSI query raises so the invocation fails and the
231 alarm fires.
232 """
233 seen = set ()
234 ordered: List[Tuple[ str , str , str ]] = []
235 kwargs: Dict[ str , Any] = {
236 "IndexName" : "metering_pending" ,
237 "KeyConditionExpression" : "meteringPending = :hb" ,
238 "ExpressionAttributeValues" : { ":hb" : hour_bucket},
239 }
240 response = usage_table.query( ** kwargs)
241 while True :
242 for item in response.get( "Items" , []):
243 arn = item.get( "licenseArn" , "" )
244 if not arn:
245 continue
246 tup = (arn, item.get( "customerAWSAccountId" , "" ), item.get( "dimension" , "" ))
247 if tup not in seen:
248 seen.add(tup)
249 ordered.append(tup)
250 if "LastEvaluatedKey" not in response:
251 break
252 kwargs[ "ExclusiveStartKey" ] = response[ "LastEvaluatedKey" ]
253 response = usage_table.query( ** kwargs)
254 return ordered
255
256
257 def get_pending_groups_for_license (hour_bucket: str , license_arn: str ) -> List[Tuple[ str , str , str ]]:
258 """TARGETED variant of get_pending_groups for a SINGLE license.
259
260 The ``metering_pending`` GSI is HASH ``meteringPending`` + RANGE ``licenseArn``,
261 so adding ``AND licenseArn = :la`` scopes the read to just this license's pending groups in
262 the bucket — O(this-license's-groups), never the whole-bucket partition across all licenses.
263 Used by the flush-deprovisioning sweep so it does not read every license's pending set to
264 isolate a handful of deprovisioning ones (a targeted read, never a full-partition scan).
265 Errors are NOT swallowed.
266 """
267 seen = set ()
268 ordered: List[Tuple[ str , str , str ]] = []
269 kwargs: Dict[ str , Any] = {
270 "IndexName" : "metering_pending" ,
271 "KeyConditionExpression" : "meteringPending = :hb AND licenseArn = :la" ,
272 "ExpressionAttributeValues" : { ":hb" : hour_bucket, ":la" : license_arn},
273 }
274 response = usage_table.query( ** kwargs)
275 while True :
276 for item in response.get( "Items" , []):
277 arn = item.get( "licenseArn" , "" )
278 if not arn:
279 continue
280 tup = (arn, item.get( "customerAWSAccountId" , "" ), item.get( "dimension" , "" ))
281 if tup not in seen:
282 seen.add(tup)
283 ordered.append(tup)
284 if "LastEvaluatedKey" not in response:
285 break
286 kwargs[ "ExclusiveStartKey" ] = response[ "LastEvaluatedKey" ]
287 response = usage_table.query( ** kwargs)
288 return ordered
289
290
291 def read_group_rows (license_arn: str , account_id: str , dimension: str , hour_bucket: str ):
292 """Targeted read of ONE (license, account, dimension, hour) group via the sort-key
293 begins_with prefix — NOT a full licenseArn-partition query + filter.
294
295 The prefix is ``{account}#{dimension}#{hourBucket}``; with SECOND precision the sort
296 key is ``{account}#{dimension}#{YYYY-MM-DDTHH:MM:SS}`` so the hour-prefix match still
297 captures every second within the hour. Streams pages (caller folds; never materializes
298 all rows beyond the returned page list).
299 """
300 prefix = f " { account_id } # { dimension } # { hour_bucket } "
301 rows = []
302 kwargs: Dict[ str , Any] = {
303 "KeyConditionExpression" : (
304 "licenseArn = :la AND begins_with(customerAWSAccountId_dimension_timestamp, :pfx)"
305 ),
306 "ExpressionAttributeValues" : { ":la" : license_arn, ":pfx" : prefix},
307 }
308 response = usage_table.query( ** kwargs)
309 rows.extend(response.get( "Items" , []))
310 while "LastEvaluatedKey" in response:
311 kwargs[ "ExclusiveStartKey" ] = response[ "LastEvaluatedKey" ]
312 response = usage_table.query( ** kwargs)
313 rows.extend(response.get( "Items" , []))
314 return rows
315
316
317 # ─── Aggregation (fold a group's rows into one record) ───────────────────────
318 def collect_group (license_arn, account_id, dimension, hour_bucket) -> Dict[ str , Any]:
319 """Read the targeted (license, account, dimension, hour) rows and fold them into one
320 aggregation group dict (sum quantity + merge VMT allocations + capture row keys).
321 """
322 hour_ts = datetime.strptime(hour_bucket, HOUR_FMT ).replace( tzinfo = timezone.utc)
323 group: Dict[ str , Any] = {
324 "license_arn" : license_arn,
325 "account_id" : account_id,
326 "dimension" : dimension,
327 "hour_bucket" : hour_bucket,
328 "hour_ts" : hour_ts,
329 "quantity" : 0 ,
330 "row_keys" : [],
331 "alloc_by_tagset" : {}, # signature -> {"tags": [...], "qty": int}
332 "tagged_nonzero_rows" : 0 ,
333 "untagged_nonzero_rows" : 0 ,
334 "reject_reason" : None ,
335 "customer_identifier" : "" ,
336 }
337 for row in read_group_rows(license_arn, account_id, dimension, hour_bucket):
338 if "meteringPending" not in row:
339 continue
340 sort_key = row.get( "customerAWSAccountId_dimension_timestamp" , "" )
341 group[ "row_keys" ].append(sort_key)
342 sk_reason = validate_sort_key(sort_key, account_id, dimension)
343 if sk_reason:
344 group[ "reject_reason" ] = group[ "reject_reason" ] or sk_reason
345 continue
346 if not group[ "customer_identifier" ]:
347 group[ "customer_identifier" ] = row.get( "customerIdentifier" , "" ) or ""
348
349 raw_qty = row.get( "quantity" , 0 )
350 qty = strict_int(raw_qty)
351 if qty is None :
352 group[ "reject_reason" ] = group[ "reject_reason" ] or quantity_reason(raw_qty)
353 continue
354 if qty < 0 :
355 group[ "reject_reason" ] = group[ "reject_reason" ] or "NegativeQuantity"
356 continue
357
358 group[ "quantity" ] += qty
359
360 row_allocs = row.get( "usageAllocations" )
361 if qty == 0 :
362 continue
363 if row_allocs:
364 group[ "tagged_nonzero_rows" ] += 1
365 reason = merge_allocations(group[ "alloc_by_tagset" ], row_allocs)
366 if reason:
367 group[ "reject_reason" ] = group[ "reject_reason" ] or reason
368 else :
369 group[ "untagged_nonzero_rows" ] += 1
370 return group
371
372
373 def merge_allocations (alloc_by_tagset, row_allocs) -> Optional[ str ]:
374 """Merge one row's seller-provided usageAllocations into the group's map, summing
375 identical tag sets. Returns a reason code if malformed, else None. The meter
376 logic NEVER invents or re-partitions tags — only carries through the seller's split.
377 """
378 if not isinstance (row_allocs, list ):
379 return "MalformedAllocations"
380 for alloc in row_allocs:
381 if not isinstance (alloc, dict ):
382 return "MalformedAllocations"
383 aq = strict_int(alloc.get( "AllocatedUsageQuantity" ))
384 if aq is None or aq < 0 :
385 return "NegativeOrNonIntegerAllocation"
386 tags = alloc.get( "Tags" , [])
387 if not isinstance (tags, list ) or not ( 1 <= len (tags) <= MAX_TAGS_PER_ALLOCATION ):
388 return (
389 "TooManyTags"
390 if isinstance (tags, list ) and len (tags) > MAX_TAGS_PER_ALLOCATION
391 else "MissingTagKeyOrValue"
392 )
393 norm = []
394 for t in tags:
395 if not isinstance (t, dict ) or not t.get( "Key" ) or not t.get( "Value" ):
396 return "MissingTagKeyOrValue"
397 norm.append(( str (t[ "Key" ]), str (t[ "Value" ])))
398 signature = tuple ( sorted (norm))
399 entry = alloc_by_tagset.setdefault(signature, { "tags" : norm, "qty" : 0 })
400 entry[ "qty" ] += aq
401 return None
402
403
404 def validate_group (group: Dict[ str , Any], now: datetime) -> Optional[ str ]:
405 """Return a specific reason code if the aggregated group is invalid, else None.
406
407 Covers a source-row reason recorded during the fold, dimension, identifier (incl.
408 LicenseArn shape, ), the 24h timestamp window, and the merged VMT allocation
409 invariants. Zero-quantity in-window handling is the caller's concern.
410 """
411 if group[ "reject_reason" ]:
412 return group[ "reject_reason" ]
413
414 license_arn = group[ "license_arn" ]
415 account_id = group[ "account_id" ]
416 dimension = group[ "dimension" ]
417 quantity = group[ "quantity" ]
418 hour_ts = group[ "hour_ts" ]
419
420 if not dimension:
421 return "MissingDimension"
422 # NOTE : dimension VALIDITY is intentionally NOT checked client-side against a configured
423 # list. BatchMeterUsage is the authority on which dimensions the catalog accepts; a bad
424 # dimension surfaces as a per-record UsageRecordResult status at submit time. This keeps
425 # the pipeline decoupled from the catalog — adding/renaming a pricing dimension needs NO
426 # stack redeploy.
427
428 if hour_ts > now:
429 return "TimestampInFuture"
430 if hour_ts < now - timedelta( hours = 24 ):
431 return "TimestampOutOfWindow"
432
433 if license_arn.startswith( "arn:" ):
434 if not _LICENSE_ARN_RE .match(license_arn):
435 return "InvalidLicenseArn"
436 if not account_id:
437 return "MissingCustomerAWSAccountId"
438 else :
439 if not group.get( "customer_identifier" ) and not account_id:
440 return "MissingLegacyIdentifier"
441
442 if group[ "tagged_nonzero_rows" ] and group[ "untagged_nonzero_rows" ]:
443 return "MixedAllocatedAndUnallocated"
444 if group[ "alloc_by_tagset" ]:
445 if len (group[ "alloc_by_tagset" ]) > MAX_ALLOCATIONS_PER_RECORD :
446 return "TooManyAllocations"
447 alloc_sum = sum (e[ "qty" ] for e in group[ "alloc_by_tagset" ].values())
448 if alloc_sum != quantity:
449 return "AllocationSumMismatch"
450 return None
451
452
453 def finalize_allocations (group: Dict[ str , Any]) -> List[Dict[ str , Any]]:
454 """Return the merged UsageAllocations list for the record, or [] to omit it."""
455 if group[ "untagged_nonzero_rows" ] or not group[ "alloc_by_tagset" ]:
456 return []
457 return [
458 {
459 "AllocatedUsageQuantity" : entry[ "qty" ],
460 "Tags" : [{ "Key" : k, "Value" : v} for k, v in entry[ "tags" ]],
461 }
462 for entry in group[ "alloc_by_tagset" ].values()
463 ]
464
465
466 # ─── BatchMeterUsage submit ──────────────────────────────────────────────────
467 def submit_records (records, keys, outcomes, use_product_code = False ) -> bool :
468 """Send one <=25-record batch, capture per-record terminal status into ``outcomes``,
469 retry transient UnprocessedRecords ONCE, and ISOLATE a request-level failure by
470 bisecting (bounded to MAX_BISECT_DEPTH levels). Returns True if any records remain
471 unprocessed and should be retried on the next run (transient, OR a still-failing sub-batch
472 left un-isolated at the bisect-depth cap).
473 """
474 if not records:
475 return False
476 return _submit(records, keys, outcomes, use_product_code, depth = 0 )
477
478
479 def _submit (records, keys, outcomes, use_product_code, depth = 0 ) -> bool :
480 key_by_identity = {_identity(records[i]): keys[i] for i in range ( len (records))}
481 kwargs: Dict[ str , Any] = { "UsageRecords" : records}
482 if use_product_code:
483 kwargs[ "ProductCode" ] = PRODUCT_CODE
484
485 try :
486 response = mp_client.batch_meter_usage( ** kwargs)
487 except ClientError as e:
488 code = e.response.get( "Error" , {}).get( "Code" , "" )
489 if _is_transient(code):
490 raise
491 if len (records) == 1 :
492 gk = keys[ 0 ]
493 # A single record isolated by bisection == a REQUEST-level BatchMeterUsage exception
494 # for THIS record (InvalidUsageDimensionException, TimestampOutOfBoundsException,
495 # InvalidTagException, InvalidProductCodeException, InvalidLicenseException, ...) — NOT
496 # a per-record Results status. Stamp the aggregated record RejectedClientSide with the
497 # exception name as reason, and emit the BatchMeterUsageException metric (by exception)
498 # here, where the real terminal status for this record is decided (after all bisecting).
499 exception = code or "RequestRejected"
500 outcomes[gk] = {
501 "MeteringRecordId" : None ,
502 "Status" : STATUS_REJECTED ,
503 "Reason" : exception,
504 "RequestException" : exception,
505 }
506 emf_metric( "BatchMeterUsageException" , 1 , exception = exception)
507 logger.warning( f "Isolated request-level BatchMeterUsage exception ( { exception } ) for a single record" )
508 return False
509 if depth >= MAX_BISECT_DEPTH :
510 # Bisect cap reached and this multi-record sub-batch still fails. Do NOT split further
511 # (that is what puts ~2N calls of load on the submitter). Leave these records PENDING
512 # (no terminal outcome stamped, so meteringPending stays set) to be retried on the next
513 # submission run, and signal that records remain unprocessed so the invocation fails and
514 # the alarm fires. The offending record still ages out at 24h if never isolated.
515 logger.warning(
516 f "Bisect depth cap ( { MAX_BISECT_DEPTH } ) reached with { len (records) } record(s) still "
517 f "failing ( { code } ); leaving them pending for the next run rather than bisecting further"
518 )
519 emf_metric( "BisectDepthExceeded" , len (records))
520 return True
521 mid = len (records) // 2
522 left = _submit(records[:mid], keys[:mid], outcomes, use_product_code, depth + 1 )
523 right = _submit(records[mid:], keys[mid:], outcomes, use_product_code, depth + 1 )
524 return left or right
525
526 _capture_results(response, key_by_identity, outcomes)
527
528 unprocessed = list (response.get( "UnprocessedRecords" , []) or [])
529 if not unprocessed:
530 return False
531
532 logger.warning( f " { len (unprocessed) } unprocessed record(s); retrying once" )
533 retry_kwargs: Dict[ str , Any] = { "UsageRecords" : unprocessed}
534 if use_product_code:
535 retry_kwargs[ "ProductCode" ] = PRODUCT_CODE
536 retry_response = mp_client.batch_meter_usage( ** retry_kwargs)
537 _capture_results(retry_response, key_by_identity, outcomes)
538
539 still_unprocessed = list (retry_response.get( "UnprocessedRecords" , []) or [])
540 if still_unprocessed:
541 logger.error(
542 f " { len (still_unprocessed) } record(s) still unprocessed after retry; "
543 "left pending for the next run"
544 )
545 emf_metric( "UsageRecordUnprocessed" , len (still_unprocessed))
546 return True
547 return False
548
549
550 def _capture_results (response, key_by_identity, outcomes):
551 for result in response.get( "Results" , []):
552 rec = result.get( "UsageRecord" , {})
553 gk = key_by_identity.get(_identity(rec))
554 if gk is not None :
555 outcomes[gk] = {
556 "MeteringRecordId" : result.get( "MeteringRecordId" ),
557 "Status" : result.get( "Status" , "" ),
558 }
559 observe_status(result.get( "Status" , "" ), rec)
560
561
562 def _is_transient (code):
563 return code in ( "ThrottlingException" , "InternalServiceException" , "ServiceUnavailable" )
564
565
566 def _identity (record):
567 return (
568 record.get( "LicenseArn" ) or record.get( "CustomerIdentifier" ) or "" ,
569 record.get( "CustomerAWSAccountId" , "" ),
570 record.get( "Dimension" , "" ),
571 _ts_iso(record.get( "Timestamp" )),
572 )
573
574
575 def _ts_iso (ts):
576 # Hour bucket is always UTC. If a tz-aware datetime is passed, CONVERT it to UTC before
577 # formatting (a non-UTC datetime would otherwise bucket in the wrong hour); a naive
578 # datetime is assumed to already be UTC (the documented writer contract).
579 if isinstance (ts, datetime):
580 if ts.tzinfo is not None :
581 ts = ts.astimezone(timezone.utc)
582 return ts.strftime( HOUR_FMT )
583 return str (ts)[: 13 ]
584
585
586 def observe_status (status, rec):
587 # Per-record Results[].Status from BatchMeterUsage is only Success / CustomerNotSubscribed /
588 # DuplicateRecord. An invalid/undefined dimension is NOT a per-record status — it is a
589 # REQUEST-level InvalidUsageDimensionException that fails the whole call and is isolated in
590 # _submit (see there), so it is NOT handled here.
591 if status in ( "Success" , "DuplicateRecord" ):
592 if status == "DuplicateRecord" :
593 emf_metric( "DuplicateRecord" , 1 )
594 return
595 logger.warning(
596 f "Metering status= { status } account= { mask(rec.get( 'CustomerAWSAccountId' , '' )) } "
597 f "dimension= { rec.get( 'Dimension' , 'unknown' ) } "
598 )
599 if status == "CustomerNotSubscribed" :
600 emf_metric( "CustomerNotSubscribed" , 1 )
601
602
603 # ─── Subscriber deprovisioning set (sparse-index query) ──────────────────────
604 # NOTE : finalization (deprovisioning -> inactive) is NOT done here or by the submitter — a
605 # license may have usage across multiple hours/regions, so it is owned by the events-stack
606 # deprovision_cleanup Lambda, which finalizes once the ~1h flush window (deprovisioningExpiry)
607 # has elapsed. This module only exposes the ACTIVE deprovisioning set for expediting.
608 def deprovisioning_licenses () -> set :
609 """Return the set of licenseArns currently in a deprovisioning flush window.
610
611 Queries the sparse ``deprovisioning-pending-index`` by its constant HASH flag — the
612 index holds ONLY licenses the subscription Lambda marked on `License Deprovisioned` and
613 has not yet finalized, so this is O(deprovisioning-count) and never a Scan. Used by the
614 discoverer's flush-deprovisioning sweep and by the submitter's deprovisioning-first
615 prioritization.
616 """
617 licenses = set ()
618 kwargs = {
619 "IndexName" : DEPROVISIONING_PENDING_INDEX ,
620 "KeyConditionExpression" : "deprovisioningPendingFlag = :v" ,
621 "ExpressionAttributeValues" : { ":v" : DEPROVISIONING_PENDING_VALUE },
622 }
623 while True :
624 response = subscribers_table.query( ** kwargs)
625 for item in response.get( "Items" , []):
626 arn = item.get( "licenseArn" )
627 if arn:
628 licenses.add(arn)
629 if "LastEvaluatedKey" not in response:
630 break
631 kwargs[ "ExclusiveStartKey" ] = response[ "LastEvaluatedKey" ]
632 return licenses
633
634
635 def deprovisioning_licenses_with_expiry () -> dict :
636 """Return ``{licenseArn: deprovisioningExpiry}`` for licenses currently in a deprovisioning window.
637
638 Same sparse-index Query as ``deprovisioning_licenses`` but keeps each license's
639 ``deprovisioningExpiry`` (= the deprovision event time + ~1h, the window-close instant; it is
640 the index RANGE key, always projected) so the flush sweep can decide whether the license's
641 CURRENT hour is within the last ~10 min before the window closes and should be flushed.
642 O(deprovisioning-count), never a Scan.
643 """
644 out = {}
645 kwargs = {
646 "IndexName" : DEPROVISIONING_PENDING_INDEX ,
647 "KeyConditionExpression" : "deprovisioningPendingFlag = :v" ,
648 "ExpressionAttributeValues" : { ":v" : DEPROVISIONING_PENDING_VALUE },
649 }
650 while True :
651 response = subscribers_table.query( ** kwargs)
652 for item in response.get( "Items" , []):
653 arn = item.get( "licenseArn" )
654 if arn:
655 out[arn] = item.get( "deprovisioningExpiry" )
656 if "LastEvaluatedKey" not in response:
657 break
658 kwargs[ "ExclusiveStartKey" ] = response[ "LastEvaluatedKey" ]
659 return out
660
661
662 # ─── Small helpers ───────────────────────────────────────────────────────────
663 def strict_int (value):
664 """Return int(value) ONLY if value is an exact integer; None otherwise."""
665 if isinstance (value, bool ):
666 return None
667 if isinstance (value, int ):
668 return value
669 try :
670 d = Decimal( str (value))
671 return int (d) if d == d.to_integral_value() else None
672 except Exception :
673 return None
674
675
676 def quantity_reason (raw):
677 """Classify why a raw quantity is not a valid non-negative integer."""
678 try :
679 Decimal( str (raw))
680 return "NonIntegerQuantity"
681 except Exception :
682 return "NonNumericQuantity"
683
684
685 def mask (identifier):
686 s = str (identifier or "" )
687 return "***" if len (s) <= 6 else f " { s[: 3 ] } *** { s[ - 3 :] } "
688
689
690 def emf_metric (name, value, reason = None , exception = None ):
691 """Emit a business-status metric via CloudWatch Embedded Metric Format (EMF).
692
693 Each metric that a ``GROUP BY`` widget/alarm consumes is emitted on a SINGLE dimension set
694 that carries the grouped key + MeteringMode, so it stays filterable/groupable
695 (WHERE ProductCode / GROUP BY Reason|Exception) WITHOUT being double-counted (emitting the
696 same value on both a bare and a MeteringMode-bearing set would make a ``GROUP BY`` query sum
697 both series ~2x):
698 - ``reason`` (UsageRecordRejected): [Reason, MeteringMode, ProductCode]. No bare, reason-less
699 [ProductCode] series, so the ``GROUP BY Reason`` widget shows no spurious "Other" series
700 (every rejection has a reason). The UsageRecordRejected alarm sums this via Metrics Insights.
701 - ``exception`` (BatchMeterUsageException): [Exception, MeteringMode, ProductCode] so the
702 request-level BatchMeterUsage exceptions (InvalidUsageDimensionException,
703 TimestampOutOfBoundsException, InvalidTagException, InvalidProductCodeException,
704 InvalidLicenseException, ...) are plotted per exception type (alarm sums via Metrics Insights).
705 - otherwise (CustomerNotSubscribed, DuplicateRecord, UsageAggregationExpired,
706 UsageSubmissionExpired, UsageRecordUnprocessed, DryRunSubmitted, ...): emitted on the bare
707 [ProductCode] set — these are consumed by PLAIN metric alarms/widgets on the [ProductCode]
708 dimension (no GROUP BY), so there is no "Other"/double-count concern and dropping the bare
709 series would leave those alarms with no datapoints (silently never firing).
710 """
711 if name == "UsageRecordRejected" and reason:
712 dimensions = [[ "Reason" , "MeteringMode" , "ProductCode" ]]
713 fields = { "Reason" : reason, "ProductCode" : PRODUCT_CODE , "MeteringMode" : METERING_MODE , name: value}
714 elif name == "BatchMeterUsageException" and exception:
715 dimensions = [[ "Exception" , "MeteringMode" , "ProductCode" ]]
716 fields = { "Exception" : exception, "ProductCode" : PRODUCT_CODE , "MeteringMode" : METERING_MODE , name: value}
717 else :
718 dimensions = [[ "ProductCode" ]]
719 fields = { "ProductCode" : PRODUCT_CODE , name: value}
720 emf = {
721 "_aws" : {
722 "Timestamp" : int (datetime.now(timezone.utc).timestamp() * 1000 ),
723 "CloudWatchMetrics" : [
724 {
725 "Namespace" : METRIC_NAMESPACE ,
726 "Dimensions" : dimensions,
727 "Metrics" : [{ "Name" : name, "Unit" : "Count" }],
728 }
729 ],
730 },
731 ** fields,
732 }
733 print (json.dumps(emf))
734
735
736 # ─── aggregated_usage keys ───────────────────────────────────────────────────
737 def agg_sort_key (account_id: str , dimension: str , hour_bucket: str ) -> str :
738 """Sort key for the aggregated_usage table: ``account#dimension#hour``."""
739 return f " { account_id } # { dimension } # { hour_bucket } "
740
741
742 # ─── Bounded, throttle-guarded per-row writes (shared by cleanup + reject) ────
743 import random # noqa: E402
744 import threading # noqa: E402
745 import time # noqa: E402
746 from concurrent.futures import ThreadPoolExecutor, as_completed # noqa: E402
747
748 DDB_MAX_WORKERS = int (os.environ.get( "CLEANUP_MAX_WORKERS" , "16" ))
749 DDB_MAX_RETRIES = int (os.environ.get( "CLEANUP_MAX_RETRIES" , "5" ))
750 _THROTTLE_CODES = (
751 "ProvisionedThroughputExceededException" ,
752 "ThrottlingException" ,
753 "RequestLimitExceeded" ,
754 )
755
756 # boto3 RESOURCE objects (like the module-level ``usage_table``) are NOT thread-safe and
757 # must not be shared across threads — only low-level clients are. The concurrent per-row
758 # writers below therefore use a per-thread resource Table (created lazily, once per worker
759 # thread) rather than the shared module-level one. This keeps the ergonomic
760 # ``.update_item(**kwargs)`` call (native Python types, no manual attribute-value
761 # marshalling) while being thread-safe.
762 _thread_local = threading.local()
763
764
765 def _tl_usage_table ():
766 tbl = getattr (_thread_local, "usage_table" , None )
767 if tbl is None :
768 tbl = boto3.resource( "dynamodb" ).Table(os.environ[ "USAGE_TABLE" ])
769 _thread_local.usage_table = tbl
770 return tbl
771
772
773 def update_item_with_backoff (license_arn, sort_key, update_expr, values, names = None ):
774 """One raw-usage-row UpdateItem with exponential backoff on per-partition throttling.
775
776 All rows of a group share the ``licenseArn`` partition, so a burst hits ONE partition
777 (~1000 WCU/s). This retries throttling with full-jitter backoff; a non-throttle error
778 (or exhausted retries) raises so the caller can fail the SQS message (redelivery/DLQ).
779 Runs on a worker thread, so it uses a THREAD-LOCAL resource Table (boto3 resources are
780 not thread-safe), never the shared module-level ``usage_table``.
781 """
782 # Audit timestamps: stamp updatedAt on every write + createdAt-once (the seller's writer owns the
783 # raw row's original createdAt; if_not_exists preserves it, else this sets it here).
784 update_expr, values, names = with_audit_timestamps(update_expr, values or {}, names)
785 kwargs: Dict[ str , Any] = {
786 "Key" : {
787 "licenseArn" : license_arn,
788 "customerAWSAccountId_dimension_timestamp" : sort_key,
789 },
790 "UpdateExpression" : update_expr,
791 }
792 if values: # a REMOVE-only expression has no values; DynamoDB rejects an empty map
793 kwargs[ "ExpressionAttributeValues" ] = values
794 if names:
795 kwargs[ "ExpressionAttributeNames" ] = names
796 attempt = 0
797 while True :
798 try :
799 _tl_usage_table().update_item( ** kwargs)
800 return
801 except ClientError as e:
802 code = e.response.get( "Error" , {}).get( "Code" , "" )
803 if code in _THROTTLE_CODES and attempt < DDB_MAX_RETRIES :
804 time.sleep( min ( 2 ** attempt, 8 ) * ( 0.5 + random.random() / 2 ))
805 attempt += 1
806 continue
807 raise
808
809
810 def update_rows_bounded (license_arn, sort_keys, update_expr, values, names = None ):
811 """Apply ``update_item_with_backoff`` to many rows of one license via a BOUNDED thread
812 pool (guards the shared-partition WCU limit per invocation). Raises if any row fails
813 after retries, listing how many failed — the caller fails the message (redelivery/DLQ),
814 never a silent drop."""
815 errors = []
816 with ThreadPoolExecutor( max_workers = DDB_MAX_WORKERS ) as pool:
817 futures = {
818 pool.submit(update_item_with_backoff, license_arn, sk, update_expr, values, names): sk
819 for sk in sort_keys
820 if sk
821 }
822 for fut in as_completed(futures):
823 exc = fut.exception()
824 if exc is not None :
825 errors.append(exc)
826 if errors:
827 raise RuntimeError (
828 f " { len (errors) } / { len (sort_keys) } UpdateItem(s) failed for "
829 f "license= { mask(license_arn) } ; first error: { type (errors[ 0 ]). __name__ } "
830 )