11# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
22#
33# SPDX-License-Identifier: Apache-2.0
4- """Asynchronous, coalescing export scheduler for the Workflow Insight plugin.
4+ """Asynchronous, bounded export scheduler for the Workflow Insight plugin.
55
66The plugin builds one canonical ``WorkflowInsight`` record on the SDK checkpoint
77thread and hands it to :class:`_ExportScheduler`. The scheduler keeps all
1616* One lazy daemon worker per exporter lane; never more than one live worker per
1717 lane, and a blocked worker is retained -- never replaced -- so threads cannot
1818 grow without bound.
19- * Per lane, at most one in-flight record and one latest * pending* record per
20- execution ARN. Records are cumulative snapshots, so a newer pending record for
21- an ARN replaces the older one (coalescing); an in-flight record is never
22- cancelled. Updating a pending ARN moves it to the back of the queue for
23- fairness across ARNs. Pending ARNs are capped; the oldest is evicted when the
24- cap is exceeded (only reachable behind a blocked/slow exporter) .
19+ * Per lane, each execution ARN has a small bounded FIFO of pending snapshots.
20+ Ordinary bursts remain observable even when Python does not schedule the
21+ worker between checkpoint hooks. Once that FIFO is full, its oldest pending
22+ snapshot is dropped so the newest progress and terminal snapshots are
23+ retained. ARNs are served round-robin for fairness, and pending ARNs are also
24+ capped; the oldest execution is evicted when that cap is exceeded .
2525* Invocation end enqueues one flush barrier per touched lane after the latest
2626 record and waits for all barriers under a single shared timeout deadline. On
2727 timeout the workflow response is returned, degradation is logged, and each
5454# bounded regardless of how long a worker stays blocked.
5555_DEFAULT_MAX_PENDING_EXECUTIONS = 1024
5656
57+ # Upper bound on all pending records in a lane. Keeping this equal to the
58+ # original distinct-execution cap preserves the scheduler's previous worst-case
59+ # record count even though one execution can now retain a short burst.
60+ _DEFAULT_MAX_PENDING_RECORDS = 1024
61+
62+ # Upper bound on records waiting for one execution in one lane. This is large
63+ # enough to preserve the known 11-progress-plus-terminal burst without relying
64+ # on daemon-thread scheduling, while still bounding memory behind a blocked
65+ # exporter. The in-flight record is not included in this count.
66+ _DEFAULT_MAX_PENDING_RECORDS_PER_EXECUTION = 16
67+
5768# Queue entry kinds.
5869_RECORD = "record"
5970_FLUSH = "flush"
@@ -95,9 +106,15 @@ def __init__(
95106 exporter : InsightExporter ,
96107 * ,
97108 max_pending_executions : int = _DEFAULT_MAX_PENDING_EXECUTIONS ,
109+ max_pending_records : int = _DEFAULT_MAX_PENDING_RECORDS ,
110+ max_pending_records_per_execution : int = (
111+ _DEFAULT_MAX_PENDING_RECORDS_PER_EXECUTION
112+ ),
98113 ) -> None :
99114 self ._exporter = exporter
100115 self ._max_pending = max (1 , max_pending_executions )
116+ self ._max_pending_records = max (1 , max_pending_records )
117+ self ._max_pending_per_execution = max (1 , max_pending_records_per_execution )
101118 # Explicit non-reentrant Lock rather than Condition()'s default RLock:
102119 # the lane never re-acquires ``_cond`` while already holding it (worker
103120 # I/O -- export/flush -- runs outside the lock and no locked helper
@@ -107,9 +124,10 @@ def __init__(
107124 self ._cond = threading .Condition (threading .Lock ())
108125 # Ordered work list: entries are (_RECORD, arn) or (_FLUSH, barrier).
109126 self ._queue : deque [tuple [str , Any ]] = deque ()
110- # arn -> latest pending record (coalesced). Insertion order is the
111- # fairness order; updating an arn moves it to the back.
112- self ._pending : OrderedDict [str , dict [str , Any ]] = OrderedDict ()
127+ # arn -> bounded FIFO of pending records. Insertion order is the ARN
128+ # fairness order; scheduling an existing ARN moves its queue token to
129+ # the back, and the worker requeues an ARN that has more records.
130+ self ._pending : OrderedDict [str , deque [dict [str , Any ]]] = OrderedDict ()
113131 self ._stop_when_idle = False
114132 self ._worker : threading .Thread | None = None
115133
@@ -119,15 +137,24 @@ def schedule(self, execution_arn: str, record: dict[str, Any]) -> None:
119137 with self ._cond :
120138 self ._stop_when_idle = False
121139 if execution_arn in self ._pending :
122- # Coalesce: replace the pending record and move it to the back so
123- # a busy execution cannot starve the others.
124- self ._pending [execution_arn ] = record
140+ pending = self ._pending [execution_arn ]
141+ if len (pending ) >= self ._max_pending_per_execution :
142+ pending .popleft ()
143+ _logger .warning (
144+ "workflow-insight: pending export FIFO for %s on %s is "
145+ "full (cap=%d); dropping oldest pending record" ,
146+ execution_arn ,
147+ type (self ._exporter ).__name__ ,
148+ self ._max_pending_per_execution ,
149+ )
150+ pending .append (record )
125151 self ._pending .move_to_end (execution_arn )
126152 self ._move_record_token_to_back (execution_arn )
127153 else :
128- self ._pending [execution_arn ] = record
154+ self ._pending [execution_arn ] = deque ([ record ])
129155 self ._queue .append ((_RECORD , execution_arn ))
130- self ._enforce_pending_cap ()
156+ self ._enforce_pending_execution_cap ()
157+ self ._enforce_pending_record_cap ()
131158 self ._ensure_worker_locked ()
132159 self ._cond .notify ()
133160
@@ -176,22 +203,54 @@ def _move_record_token_to_back(self, execution_arn: str) -> None:
176203 del self ._queue [index ]
177204 self ._queue .append ((_RECORD , execution_arn ))
178205 return
179- # No token means the arn is currently in flight; a fresh token will be
180- # appended when it leaves flight (the next schedule sees it absent from
181- # ``_pending``), which yields the "export A then latest" behavior .
206+ # No token means the ARN's last queued record is currently in flight.
207+ # The next schedule sees it absent from ``_pending`` and appends a fresh
208+ # FIFO plus token, preserving the in-flight record before new work .
182209
183- def _enforce_pending_cap (self ) -> None :
210+ def _requeue_record_before_flush (self , execution_arn : str ) -> None :
211+ """Requeue an ARN behind peer records but before its drain barrier.
212+
213+ A record token represents the ARN's whole pending FIFO at the moment the
214+ barrier is enqueued. Consuming one record must not move the remaining
215+ pre-barrier records behind that barrier, or invocation end could flush
216+ and return while part of its FIFO is still waiting.
217+ """
218+ for index , (kind , _ ) in enumerate (self ._queue ):
219+ if kind == _FLUSH :
220+ self ._queue .insert (index , (_RECORD , execution_arn ))
221+ return
222+ self ._queue .append ((_RECORD , execution_arn ))
223+
224+ def _enforce_pending_execution_cap (self ) -> None :
184225 while len (self ._pending ) > self ._max_pending :
185226 old_arn , _ = self ._pending .popitem (last = False )
186227 self ._remove_record_token (old_arn )
187228 _logger .warning (
188229 "workflow-insight: export lane for %s is full "
189- "(cap=%d); dropping pending record for %s" ,
230+ "(cap=%d); dropping pending records for %s" ,
190231 type (self ._exporter ).__name__ ,
191232 self ._max_pending ,
192233 old_arn ,
193234 )
194235
236+ def _enforce_pending_record_cap (self ) -> None :
237+ while sum (len (records ) for records in self ._pending .values ()) > (
238+ self ._max_pending_records
239+ ):
240+ old_arn = next (iter (self ._pending ))
241+ records = self ._pending [old_arn ]
242+ records .popleft ()
243+ _logger .warning (
244+ "workflow-insight: export lane for %s reached its pending "
245+ "record cap (%d); dropping oldest pending record for %s" ,
246+ type (self ._exporter ).__name__ ,
247+ self ._max_pending_records ,
248+ old_arn ,
249+ )
250+ if not records :
251+ del self ._pending [old_arn ]
252+ self ._remove_record_token (old_arn )
253+
195254 def _remove_record_token (self , execution_arn : str ) -> None :
196255 for index , (kind , payload ) in enumerate (self ._queue ):
197256 if kind == _RECORD and payload == execution_arn :
@@ -228,9 +287,17 @@ def _run_worker(self) -> None:
228287 kind , payload = self ._queue .popleft ()
229288 record : dict [str , Any ] | None = None
230289 if kind == _RECORD :
231- record = self ._pending .pop (payload , None )
232- if record is None :
290+ pending = self ._pending .get (payload )
291+ if not pending :
233292 continue
293+ record = pending .popleft ()
294+ if pending :
295+ # Round-robin across ARNs: one record per turn, then the
296+ # ARN goes behind all queue entries already waiting.
297+ self ._pending .move_to_end (payload )
298+ self ._requeue_record_before_flush (payload )
299+ else :
300+ del self ._pending [payload ]
234301
235302 if kind == _RECORD and record is not None :
236303 self ._export_one (record )
@@ -299,6 +366,10 @@ def _pending_count(self) -> int:
299366 with self ._cond :
300367 return len (self ._pending )
301368
369+ def _pending_record_count (self ) -> int :
370+ with self ._cond :
371+ return sum (len (records ) for records in self ._pending .values ())
372+
302373 def _queue_len (self ) -> int :
303374 with self ._cond :
304375 return len (self ._queue )
@@ -316,9 +387,18 @@ def __init__(
316387 exporters : list [InsightExporter ],
317388 * ,
318389 max_pending_executions : int = _DEFAULT_MAX_PENDING_EXECUTIONS ,
390+ max_pending_records : int = _DEFAULT_MAX_PENDING_RECORDS ,
391+ max_pending_records_per_execution : int = (
392+ _DEFAULT_MAX_PENDING_RECORDS_PER_EXECUTION
393+ ),
319394 ) -> None :
320395 self ._lanes = [
321- _ExporterLane (exporter , max_pending_executions = max_pending_executions )
396+ _ExporterLane (
397+ exporter ,
398+ max_pending_executions = max_pending_executions ,
399+ max_pending_records = max_pending_records ,
400+ max_pending_records_per_execution = max_pending_records_per_execution ,
401+ )
322402 for exporter in exporters
323403 ]
324404
0 commit comments