Setting the file. One moment.
Submitter · 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
scripts/ submitter.py
Python · 313 lines · 15 KB
final usage is flushed within the ~1h window. It does NOT finalize the subscriber — flipping
15 subscriptionStatus to 'inactive' is done by the separate events-stack deprovision-cleanup
16 Lambda once the deprovisioningExpiry window has elapsed (see the handler note below and
17 scripts/deprovision_cleanup.py), because a license may span multiple hours/regions and no
18 single submitter run can know it is fully drained.
19
20 Idempotency: BatchMeterUsage is first-write-wins deduped per
21 (CustomerAWSAccountId + LicenseArn + dimension + hour) per region, so a re-submitted
22 identical record is not double-billed. A record whose write-back was lost keeps
23 meteringPending and is re-submitted next run (deduped).
24 """
25
26 import os
27 from datetime import datetime, timedelta, timezone
28 from typing import Any, Dict, List, Tuple
29
30 from handlers import metering_core as core
31
32 logger = core.logger
33
34 # Max aggregated records to drain per invocation. reserved=1 is a single serial submitter;
35 # at 25 records/BatchMeterUsage call and the ~10 TPS API limit this is ~3000 calls, well
36 # within a 5-minute run — so the cap is API-feasible, not an artificial low ceiling.
37 MAX_RECORDS_PER_RUN = int (os.environ.get( "MAX_RECORDS_PER_RUN" , "50000" ))
38
39 # Optional TTL (days) for finalized aggregated_usage rows (TTL config). 0 = disabled
40 # (default): the table has no TTL spec, so we never set `ttl`. When >0, set `ttl` ONLY on
41 # terminal Success rows so pending/failed rows are never auto-expired.
42 AGGREGATED_USAGE_TTL_DAYS = int (os.environ.get( "AGGREGATED_USAGE_TTL_DAYS" , "0" ))
43
44 # MeteringMode decides prod (live) vs non-prod (dry-run). In dry-run the submitter runs the
45 # ENTIRE pipeline — read, build batches, deprovisioning-first ordering — but NEVER calls
46 # BatchMeterUsage/MeterUsage: it logs + EMF-emits the record(s) it WOULD have sent and writes
47 # meteringStatus=DryRunSubmitted. This lets a non-prod stage exercise everything without ever
48 # billing a real buyer. `live` is the only mode that calls the real API.
49 METERING_MODE = os.environ.get( "METERING_MODE" , "live" ).strip().lower()
50 DRY_RUN = METERING_MODE == "dry-run"
51 STATUS_DRY_RUN = "DryRunSubmitted"
52
53
54 def _dry_run_submit (batch, keys, outcomes, use_product_code = False ):
55 """Dry-run stand-in for core.submit_records: NEVER calls BatchMeterUsage. Records the
56 would-be submission as DryRunSubmitted (masked) and emits a metric. Returns False
57 (nothing is ever 'unprocessed' in dry-run)."""
58 for i, rec in enumerate (batch):
59 gk = keys[i]
60 outcomes[gk] = { "MeteringRecordId" : None , "Status" : STATUS_DRY_RUN }
61 logger.info(
62 "DRY-RUN (MeteringMode=dry-run) — would submit UsageRecord: "
63 f "license= { core.mask(rec.get( 'LicenseArn' , '' )) } "
64 f "account= { core.mask(rec.get( 'CustomerAWSAccountId' , '' )) } "
65 f "dimension= { rec.get( 'Dimension' , '' ) } quantity= { rec.get( 'Quantity' ) } "
66 f "(use_product_code= { use_product_code } ); NOT calling BatchMeterUsage"
67 )
68 core.emf_metric( "DryRunSubmitted" , len (batch))
69 return False
70
71
72 def handler (event, context):
73 if core.aggregated_usage_table is None :
74 raise RuntimeError ( "AGGREGATED_USAGE_TABLE not configured" )
75
76 now = datetime.now(timezone.utc)
77
78 # Deprovisioning-first: read the deprovisioning set's pending records SEPARATELY and submit
79 # them AHEAD of the ordinary backlog, so a closing-window license is not delayed behind a
80 # large oldest-first backlog (and is not lost to the MAX_RECORDS_PER_RUN cap on the regular
81 # read). Best-effort: if the deprovisioning-index lookup fails (throttle/transient/misconfig)
82 # we log and proceed with the ordinary pass only — a prioritization lookup failure must NOT
83 # block ordinary metering submission.
84 try :
85 deprovisioning = core.deprovisioning_licenses()
86 except Exception :
87 logger.warning(
88 "deprovisioning_licenses() lookup failed; submitting ordinary backlog only "
89 "(deprovisioning records still submit via the regular pass)" ,
90 exc_info = True ,
91 )
92 deprovisioning = set ()
93
94 pending: Dict[Tuple[ str , str ], Dict[ str , Any]] = {}
95 if deprovisioning:
96 pending.update(_read_deprovisioning_pending(now, deprovisioning))
97 # Regular oldest-first pass fills the remainder up to the cap (skips keys already read).
98 _read_pending_aggregated(now, into = pending)
99 if not pending:
100 logger.info( "No pending aggregated usage to submit" )
101 return { "submitted" : 0 }
102
103 ca_batches, legacy_batches = _build_batches(pending, deprovisioning)
104
105 outcomes: Dict[Any, Dict[ str , Any]] = {}
106 unprocessed_remaining = False
107 _submit = _dry_run_submit if DRY_RUN else core.submit_records
108 for batch, keys in ca_batches:
109 unprocessed_remaining |= _submit(batch, keys, outcomes, use_product_code = False )
110 for batch, keys in legacy_batches:
111 unprocessed_remaining |= _submit(batch, keys, outcomes, use_product_code = True )
112
113 for agg_key, outcome in outcomes.items():
114 _write_back(pending[agg_key], outcome)
115
116 # NOTE : the submitter does NOT flip subscriptionStatus to inactive. A deprovisioning
117 # license may have usage across multiple hours AND multiple regions (one submitter per
118 # region, one shared subscribers table), so no single submitter run can know the license
119 # is fully drained. Finalization (deprovisioning -> inactive + clearing the deprovisioning
120 # markers) is owned solely by the events-stack cleanup Lambda, which fires once the ~1h
121 # flush window (deprovisioningExpiry) has elapsed — after which no region can meter it.
122 # The submitter's only deprovisioning role is prioritization (deprovisioning records first).
123
124 logger.info(
125 f "Submitted { len (outcomes) } aggregated record(s) across "
126 f " { len ( set (k[ 0 ] for k in outcomes)) } license(s)"
127 )
128
129 if unprocessed_remaining:
130 raise RuntimeError (
131 "One or more UsageRecords remained unprocessed after retry; their "
132 "aggregated_usage meteringPending markers were kept for re-submission."
133 )
134 return { "submitted" : len (outcomes)}
135
136
137 def _read_deprovisioning_pending (now, deprovisioning) -> Dict[Tuple[ str , str ], Dict[ str , Any]]:
138 """Read pending aggregated records for the deprovisioning set, across the full window
139 INCLUDING the current in-progress hour (offset 0..23), via a TARGETED per-license query on
140 the aggregated_usage metering_pending GSI (`meteringPending = :hb AND licenseArn = :la` —
141 the GSI RANGE key). Returns ALL of them
142 (NOT subject to MAX_RECORDS_PER_RUN) so a closing-window license is never left behind the
143 ordinary oldest-first backlog or dropped by the regular read cap. O(deprovisioning-records)."""
144 out: Dict[Tuple[ str , str ], Dict[ str , Any]] = {}
145 base = now.replace( minute = 0 , second = 0 , microsecond = 0 )
146 # Include the CURRENT hour (offset 0) as well as now-1h..now-23h: the flush-deprovisioning
147 # sweep enqueues a deprovisioning license's current-hour groups near its window close, so a
148 # current-hour aggregated record can be pending and MUST be submitted before the window
149 # closes. (The ORDINARY pass never reads the in-progress current hour; the deprovisioning
150 # pass does, because for a deprovisioning license the window can close inside it.)
151 for hours_back in range ( 23 , - 1 , - 1 ):
152 hour_bucket = (base - timedelta( hours = hours_back)).strftime(core. HOUR_FMT )
153 for license_arn in deprovisioning:
154 kwargs: Dict[ str , Any] = {
155 "IndexName" : "metering_pending" ,
156 "KeyConditionExpression" : "meteringPending = :hb AND licenseArn = :la" ,
157 "ExpressionAttributeValues" : { ":hb" : hour_bucket, ":la" : license_arn},
158 }
159 while True :
160 response = core.aggregated_usage_table.query( ** kwargs)
161 for item in response.get( "Items" , []):
162 out[(item[ "licenseArn" ], item[ "account_dimension_hour" ])] = item
163 if "LastEvaluatedKey" not in response:
164 break
165 kwargs[ "ExclusiveStartKey" ] = response[ "LastEvaluatedKey" ]
166 return out
167
168
169 def _read_pending_aggregated (now, into = None ) -> Dict[Tuple[ str , str ], Dict[ str , Any]]:
170 """Read pending aggregated records via the aggregated_usage metering_pending GSI over
171 the submittable window ``now-23h … now-1h``, OLDEST-first.
172
173 Oldest-first is deliberate: an offset-23 record is closest to falling out of the 24h
174 BatchMeterUsage window, so it is drained first. Fills up to MAX_RECORDS_PER_RUN total per
175 run so the backlog flushes fast; ``into`` (the already-read deprovisioning-first records)
176 is preserved and counted toward the cap, and its keys are not re-read. Records that DID age
177 past 24h are handled by the separate scheduled expiry Lambda over aggregated_usage — NOT
178 here."""
179 pending: Dict[Tuple[ str , str ], Dict[ str , Any]] = into if into is not None else {}
180 base = now.replace( minute = 0 , second = 0 , microsecond = 0 )
181 for hours_back in range ( 23 , 0 , - 1 ): # 23 (oldest) .. 1 (newest completed hour)
182 hour_bucket = (base - timedelta( hours = hours_back)).strftime(core. HOUR_FMT )
183 kwargs: Dict[ str , Any] = {
184 "IndexName" : "metering_pending" ,
185 "KeyConditionExpression" : "meteringPending = :hb" ,
186 "ExpressionAttributeValues" : { ":hb" : hour_bucket},
187 }
188 while True :
189 response = core.aggregated_usage_table.query( ** kwargs)
190 for item in response.get( "Items" , []):
191 key = (item[ "licenseArn" ], item[ "account_dimension_hour" ])
192 if key not in pending: # keep the deprovisioning-first read; don't overwrite
193 pending[key] = item
194 if len (pending) >= MAX_RECORDS_PER_RUN :
195 return pending
196 if "LastEvaluatedKey" not in response:
197 break
198 kwargs[ "ExclusiveStartKey" ] = response[ "LastEvaluatedKey" ]
199 return pending
200
201
202 def _build_batches (pending, deprovisioning_licenses = None ):
203 """Build BatchMeterUsage records from aggregated rows, split into CA and legacy <=25
204 batches. Each aggregated row is already validated + merged by the aggregator, so the
205 submitter does not re-validate; it only shapes the API records.
206
207 Records for a license currently in its deprovisioning flush window
208 (``deprovisioning_licenses``, queried once from the sparse deprovisioning-pending-index)
209 are ordered FIRST so a closing-window license is drained ahead of ordinary backlog; within
210 each tier the existing oldest-first ordering of ``pending`` (now-23h -> now-1h) is preserved
211 (dict insertion order). This only reorders which records fill the first BatchMeterUsage
212 batches — it does not change what is submitted, the dedup, or the cadence."""
213 deprovisioning_licenses = deprovisioning_licenses or set ()
214 # Deprovisioning-first, otherwise preserve insertion (oldest-first) order.
215 ordered_items = sorted (
216 pending.items(),
217 key =lambda kv: 0 if kv[ 1 ].get( "licenseArn" ) in deprovisioning_licenses else 1 ,
218 )
219
220 ca_records, ca_keys = [], []
221 legacy_records, legacy_keys = [], []
222
223 for agg_key, item in ordered_items:
224 license_arn = item[ "licenseArn" ]
225 account_id = item.get( "customerAWSAccountId" , "" )
226 dimension = item[ "dimension" ]
227 quantity = core.strict_int(item.get( "quantity" , 0 )) or 0
228 hour_ts = datetime.strptime(item[ "hourBucket" ], core. HOUR_FMT ).replace(
229 tzinfo = timezone.utc
230 )
231
232 record: Dict[ str , Any] = {
233 "Timestamp" : hour_ts,
234 "Dimension" : dimension,
235 "Quantity" : quantity,
236 }
237 allocations = item.get( "usageAllocations" )
238 if allocations:
239 record[ "UsageAllocations" ] = _to_api_allocations(allocations)
240
241 if license_arn.startswith( "arn:" ):
242 record[ "CustomerAWSAccountId" ] = account_id
243 record[ "LicenseArn" ] = license_arn
244 ca_records.append(record)
245 ca_keys.append(agg_key)
246 else :
247 record[ "CustomerIdentifier" ] = item.get( "customerIdentifier" , "" )
248 legacy_records.append(record)
249 legacy_keys.append(agg_key)
250
251 return _chunk(ca_records, ca_keys), _chunk(legacy_records, legacy_keys)
252
253
254 def _to_api_allocations (allocations) -> List[Dict[ str , Any]]:
255 """Normalize stored usageAllocations to the BatchMeterUsage shape (ints, not Decimal)."""
256 out = []
257 for alloc in allocations:
258 out.append(
259 {
260 "AllocatedUsageQuantity" : core.strict_int(alloc.get( "AllocatedUsageQuantity" )) or 0 ,
261 "Tags" : [
262 { "Key" : t[ "Key" ], "Value" : t[ "Value" ]} for t in alloc.get( "Tags" , [])
263 ],
264 }
265 )
266 return out
267
268
269 def _chunk (records, keys):
270 return [
271 (records[i : i + core. BATCH_SIZE ], keys[i : i + core. BATCH_SIZE ])
272 for i in range ( 0 , len (records), core. BATCH_SIZE )
273 ]
274
275
276 def _write_back (item, outcome):
277 """Persist MeteringRecordId + Status (+ reason) and REMOVE meteringPending on the
278 aggregated_usage record. Not written to the raw usage table."""
279 status = outcome.get( "Status" , "" )
280 metering_record_id = outcome.get( "MeteringRecordId" )
281 reason = outcome.get( "Reason" )
282
283 update = "REMOVE meteringPending SET meteringStatus = :st"
284 values: Dict[ str , Any] = { ":st" : status}
285 if metering_record_id:
286 update += ", meteringRecordId = :mid"
287 values[ ":mid" ] = metering_record_id
288 if reason:
289 update += ", meteringStatusReason = :rsn"
290 values[ ":rsn" ] = reason
291 # Set a TTL epoch ONLY on a terminal Success row when retention is configured (>0), so
292 # a finalized record self-prunes but pending/failed rows are never auto-expired.
293 if AGGREGATED_USAGE_TTL_DAYS > 0 and status == "Success" :
294 ttl_epoch = int (
295 (datetime.now(timezone.utc).timestamp()) + AGGREGATED_USAGE_TTL_DAYS * 86400
296 )
297 update += ", #ttl = :ttl"
298 values[ ":ttl" ] = ttl_epoch
299
300 update, values, _ = core.with_audit_timestamps(update, values)
301 kwargs: Dict[ str , Any] = {
302 "Key" : {
303 "licenseArn" : item[ "licenseArn" ],
304 "account_dimension_hour" : item[ "account_dimension_hour" ],
305 },
306 "UpdateExpression" : update,
307 "ExpressionAttributeValues" : values,
308 }
309 # `ttl` is not a DynamoDB reserved word, but use a name placeholder for safety/clarity.
310 if AGGREGATED_USAGE_TTL_DAYS > 0 and status == "Success" :
311 kwargs[ "ExpressionAttributeNames" ] = { "#ttl" : "ttl" }
312
313 core.aggregated_usage_table.update_item( ** kwargs)