Setting the file. One moment.
Discoverer · 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/ discoverer.py
Python · 274 lines · 13 KB
15
previous-month bucket drops to lock 0 (immediate) from 03:00 UTC on the 1st until the
16 06:00 UTC month-end service cutoff, so previous-month reconciliation flushes in time.
17
18 ageout mode:
19 Enumerate lookback buckets older than 24h whose (hours_back % 7) == shard, and for each
20 stale pending row perform the terminal age-out directly: REMOVE meteringPending, set
21 meteringStatus=AggregationExpired, emit the UsageAggregationExpired metric. Age-out is a cheap UpdateItem that
22 does not need the aggregation pipeline. Previous-month buckets are NOT aged out while the
23 month-end grace is open (on the 1st before 06:00 UTC) — the service still accepts them.
24 """
25
26 import json
27 import os
28 from datetime import datetime, timedelta, timezone
29 from typing import Any, Dict, List
30
31 import boto3
32
33 from handlers import metering_core as core
34
35 logger = core.logger
36
37 sqs = boto3.client( "sqs" )
38 WORK_QUEUE_URL = os.environ.get( "WORK_QUEUE_URL" , "" )
39 # Deprovisioning flush enqueues to a DEDICATED work queue (its own aggregator ESM) so a
40 # deprovisioning backlog is isolated from — never queued behind — the regular hourly backlog.
41 DEPROVISION_WORK_QUEUE_URL = os.environ.get( "DEPROVISION_WORK_QUEUE_URL" , "" )
42
43 # For a deprovisioning license, also flush its CURRENT (in-progress) hour once we are within
44 # this lead time of the license's deprovisioningExpiry (the ~1h window close) — leaving ~10 min
45 # for the pipeline (sweep -> aggregator -> rate(5m) submitter) to drain the current hour before
46 # the window closes, while reserving the current hour until then for the seller's final writes.
47 CURRENT_HOUR_LEAD = timedelta( minutes = 10 )
48
49
50 def _parse_iso (ts):
51 """Parse an ISO-8601 UTC timestamp (e.g. '2026-09-16T15:00:00Z') to an aware datetime,
52 or None if absent/unparseable (caller then conservatively does NOT flush the current hour)."""
53 if not ts:
54 return None
55 try :
56 dt = datetime.fromisoformat( str (ts).replace( "Z" , "+00:00" ))
57 except ValueError :
58 return None
59 return dt if dt.tzinfo else dt.replace( tzinfo = timezone.utc)
60
61 # Seller-configured lock period: an hour is not aggregated/submitted until it has
62 # been closed for MeteringLockHours hours. meter-mode invocations whose hourOffset is below
63 # the lock no-op, giving the seller's writers that many hours to send late usage for the
64 # hour before it is locked and submitted. Default 1 (process a fully-complete hour).
65 METERING_LOCK_HOURS = int (os.environ.get( "METERING_LOCK_HOURS" , "1" ))
66
67 # Age-out lookback bounds (mirror the previous meter_usage age-out sweep, sharded by 7).
68 AGEOUT_EMPTY_STREAK_STOP = 6
69 AGEOUT_MAX_HOURS_BACK = 24 * 400
70 AGEOUT_SHARDS = 7
71
72
73 def handler (event, context):
74 mode = (event or {}).get( "mode" )
75 if mode == "meter" :
76 return _handle_meter( int (event[ "hourOffset" ]))
77 if mode == "ageout" :
78 return _handle_ageout( int (event[ "shard" ]))
79 if mode == "flush-deprovisioning" :
80 return _handle_flush_deprovisioning()
81 raise RuntimeError ( f "Discoverer invoked without a valid mode/offset: { event !r} " )
82
83
84 def _handle_meter (hour_offset: int ):
85 if not ( 1 <= hour_offset <= 23 ):
86 raise RuntimeError ( f "hourOffset out of range: { hour_offset } " )
87 if not WORK_QUEUE_URL :
88 raise RuntimeError ( "WORK_QUEUE_URL not configured" )
89
90 now = datetime.now(timezone.utc)
91 hour_bucket = core.hour_bucket_for_offset(now, hour_offset)
92
93 # Month-aware lock: current-month buckets use the configured lock; a
94 # previous-month bucket drops to lock 0 (immediate) from 03:00 UTC on the 1st until the
95 # 06:00 UTC service cutoff, so month-end reconciliation flushes before the grace closes.
96 lock = core.effective_lock_hours(hour_bucket, now, METERING_LOCK_HOURS )
97 if hour_offset < lock:
98 logger.info(
99 f "hourOffset { hour_offset } < effective lock { lock } for bucket { hour_bucket } : "
100 "still open for late usage; no-op"
101 )
102 return { "hourOffset" : hour_offset, "lock" : lock, "enqueued" : 0 , "noop" : True }
103
104 groups = core.get_pending_groups(hour_bucket)
105 if not groups:
106 logger.info( f "No pending usage for hour bucket { hour_bucket } (offset { hour_offset } )" )
107 return { "hourBucket" : hour_bucket, "enqueued" : 0 }
108
109 enqueued = _enqueue_work(groups, hour_bucket)
110 logger.info( f "Enqueued { enqueued } group(s) for hour bucket { hour_bucket } " )
111 return { "hourBucket" : hour_bucket, "enqueued" : enqueued}
112
113
114 def _handle_flush_deprovisioning ():
115 """Expedited flush for licenses in their ~1-hour deprovisioning window.
116
117 Runs on a rate(5m) rule (independent of the ordinary hourly meter rules, which are
118 UNCHANGED). Queries the sparse deprovisioning-pending-index for licenses the subscription
119 Lambda marked on `License Deprovisioned`, then enqueues each such license's pending groups
120 over now-23h .. now-1h to the DEDICATED deprovision-work queue (isolated from the regular
121 backlog), IGNORING MeteringLockHours so the aggregator + 5-min submitter can flush them
122 before the window closes. The CURRENT (in-progress) hour is normally RESERVED (left for the
123 seller's own direct writes/final events), BUT is ALSO flushed for a license once we are
124 within CURRENT_HOUR_LEAD (10 min) of that license's deprovisioningExpiry (the ~1h window
125 close) — so the current-hour usage still traverses the pipeline before the window closes.
126 Idempotent: the aggregator conditional-put + BatchMeterUsage first-write-wins dedup prevent
127 a double-bill if the ordinary run later re-enqueues the same group.
128 """
129 if not DEPROVISION_WORK_QUEUE_URL :
130 raise RuntimeError ( "DEPROVISION_WORK_QUEUE_URL not configured" )
131
132 # {licenseArn: deprovisioningExpiry} — expiry (= deprovision event time + ~1h, the window
133 # close) gates whether the CURRENT hour should now be flushed (see CURRENT_HOUR_LEAD below).
134 deprovisioning = core.deprovisioning_licenses_with_expiry()
135 if not deprovisioning:
136 logger.info( "flush-deprovisioning: no licenses in a deprovisioning window" )
137 return { "deprovisioningLicenses" : 0 , "enqueued" : 0 }
138
139 now = datetime.now(timezone.utc)
140
141 # Current-hour flush is gated on the window close: for each deprovisioning license, also
142 # sweep the CURRENT (in-progress) hour ONCE we are within CURRENT_HOUR_LEAD (10 min) of the
143 # license's deprovisioningExpiry, so the current-hour usage still traverses discoverer ->
144 # aggregator -> submitter before the ~1-hour window closes, while leaving the current hour
145 # reserved until then for the seller's final writes. Flush when now >= expiry - 10min.
146 current_hour_licenses = set ()
147 for license_arn, expiry in deprovisioning.items():
148 exp = _parse_iso(expiry)
149 if exp is not None and now >= exp - CURRENT_HOUR_LEAD :
150 current_hour_licenses.add(license_arn)
151
152 total_enqueued = 0
153 # offset 0 = current hour (only for settled licenses); offsets 1..23 = completed hours (all).
154 for hour_offset in range ( 0 , 24 ):
155 hour_bucket = core.hour_bucket_for_offset(now, hour_offset)
156 licenses_for_bucket = (
157 current_hour_licenses if hour_offset == 0 else deprovisioning.keys()
158 )
159 if not licenses_for_bucket:
160 continue
161 # TARGETED read: query the metering_pending GSI per deprovisioning license
162 # (meteringPending=:hb AND licenseArn=:la) rather than reading the whole bucket
163 # partition across ALL licenses and filtering in memory — O(deprovisioning-groups),
164 # never a full-partition scan. Lock is deliberately NOT consulted here —
165 # that is the whole point of expediting.
166 groups = []
167 for license_arn in licenses_for_bucket:
168 groups.extend(core.get_pending_groups_for_license(hour_bucket, license_arn))
169 if groups:
170 total_enqueued += _enqueue_work(groups, hour_bucket, DEPROVISION_WORK_QUEUE_URL )
171 logger.info(
172 f "flush-deprovisioning: enqueued { total_enqueued } group(s) across "
173 f " { len (deprovisioning) } deprovisioning license(s) "
174 f "( { len (current_hour_licenses) } also current-hour-flushed) (lock bypassed)"
175 )
176 return { "deprovisioningLicenses" : len (deprovisioning), "enqueued" : total_enqueued}
177
178
179 def _enqueue_work (groups, hour_bucket, queue_url = None ) -> int :
180 """SendMessageBatch one message per (licenseArn, account, dimension, hourBucket) group.
181 ``queue_url`` defaults to the regular work queue; the deprovisioning flush passes the
182 dedicated deprovision-work queue so its backlog is isolated from the regular one."""
183 queue_url = queue_url or WORK_QUEUE_URL
184 enqueued = 0
185 batch: List[Dict[ str , Any]] = []
186 for license_arn, account_id, dimension in groups:
187 body = {
188 "licenseArn" : license_arn,
189 "customerAWSAccountId" : account_id,
190 "dimension" : dimension,
191 "hourBucket" : hour_bucket,
192 }
193 batch.append(
194 { "Id" : str ( len (batch)), "MessageBody" : json.dumps(body)}
195 )
196 if len (batch) == 10 : # SQS SendMessageBatch max 10
197 _flush_batch(batch, queue_url)
198 enqueued += len (batch)
199 batch = []
200 if batch:
201 _flush_batch(batch, queue_url)
202 enqueued += len (batch)
203 return enqueued
204
205
206 def _flush_batch (batch, queue_url):
207 resp = sqs.send_message_batch( QueueUrl = queue_url, Entries = batch)
208 failed = resp.get( "Failed" , [])
209 if failed:
210 # Do not swallow: a failed enqueue means those groups would silently not be
211 # metered. Raise so the invocation fails and the alarm fires; the rows keep
212 # meteringPending and are re-discovered next hour.
213 raise RuntimeError ( f "SQS SendMessageBatch had { len (failed) } failure(s): { failed } " )
214
215
216 def _handle_ageout (shard: int ):
217 """Age out pending rows > 24h old whose (hours_back % 7) == shard."""
218 if not ( 0 <= shard < AGEOUT_SHARDS ):
219 raise RuntimeError ( f "shard out of range: { shard } " )
220 now = datetime.now(timezone.utc)
221 base = now.replace( minute = 0 , second = 0 , microsecond = 0 )
222 from datetime import timedelta
223
224 hours_back = 24
225 empty_streak = 0
226 aged = 0
227 while hours_back <= AGEOUT_MAX_HOURS_BACK and empty_streak < AGEOUT_EMPTY_STREAK_STOP :
228 if hours_back % AGEOUT_SHARDS != shard:
229 hours_back += 1
230 continue
231 hour_bucket = (base - timedelta( hours = hours_back)).strftime(core. HOUR_FMT )
232 hours_back += 1
233 # do NOT age out a PREVIOUS-month bucket while the month-end grace is open
234 # (on the 1st before 06:00 UTC) — the service still accepts it, and age-out here
235 # would expire exactly the records the reconciliation window is meant to save.
236 if core.previous_month_grace_open(now) and core.is_previous_month_bucket(hour_bucket, now):
237 continue
238 pending = core.get_pending_groups(hour_bucket)
239 if not pending:
240 empty_streak += 1
241 continue
242 empty_streak = 0
243 aged += _expire_bucket(pending, hour_bucket)
244 logger.info( f "Age-out shard { shard } : expired { aged } row(s)" )
245 return { "shard" : shard, "expired" : aged}
246
247
248 def _expire_bucket (pending, hour_bucket) -> int :
249 aged = 0
250 for license_arn, account_id, dimension in pending:
251 for row in core.read_group_rows(license_arn, account_id, dimension, hour_bucket):
252 if "meteringPending" not in row:
253 continue
254 sort_key = row.get( "customerAWSAccountId_dimension_timestamp" , "" )
255 logger.warning(
256 "AGE-OUT: dropping pending usage older than 24h (unbillable) "
257 f "license= { core.mask(license_arn) } account= { core.mask(account_id) } "
258 f "dim= { dimension } hour= { hour_bucket } "
259 )
260 core.emf_metric( "UsageAggregationExpired" , 1 )
261 _expr, _vals, _ = core.with_audit_timestamps(
262 "REMOVE meteringPending SET meteringStatus = :st" ,
263 { ":st" : "AggregationExpired" },
264 )
265 core.usage_table.update_item(
266 Key = {
267 "licenseArn" : license_arn,
268 "customerAWSAccountId_dimension_timestamp" : sort_key,
269 },
270 UpdateExpression = _expr,
271 ExpressionAttributeValues = _vals,
272 )
273 aged += 1
274 return aged