Skip to content

Commit efae931

Browse files
author
Alex Wang
authored
fix(insight): buffer on-change export bursts
1 parent 12acbb4 commit efae931

5 files changed

Lines changed: 290 additions & 57 deletions

File tree

packages/aws-durable-execution-sdk-python-insight/README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,23 @@ Behavior is validated cross-SDK by the `insight` conformance suite
6262
> single background worker, every entry in `exporters` must be a **distinct
6363
> instance**: passing the same object twice raises `ValueError` at construction.
6464
> Two separate instances of the same exporter class (e.g. two `S3Exporter`s for
65-
> different buckets) are fine — each gets its own worker. Rapid cumulative
66-
> snapshots for one execution are coalesced, so a lane may skip intermediate
67-
> `on-change` records; the terminal record is always delivered under normal
68-
> completion. At invocation end the plugin drains and flushes the touched
69-
> exporters under a single shared deadline
65+
> different buckets) are fine — each gets its own worker. Each lane keeps a
66+
> bounded FIFO of up to 16 pending snapshots per execution and at most 1,024
67+
> pending snapshots across the lane, preserving ordinary bursts without relying
68+
> on daemon-thread scheduling. If an exporter remains slower than the producer
69+
> and either bound fills, the oldest pending snapshot is dropped so the newest progress and terminal snapshots are retained. At
70+
> invocation end the plugin drains and flushes the touched exporters under a
71+
> single shared deadline
7072
> (`WorkflowInsightConfig.export_timeout_seconds`, default `5.0`); on timeout the
7173
> workflow response is returned and record delivery degrades to best-effort.
74+
>
75+
> `flush()` is lane-wide, not execution-scoped: it applies to the configured
76+
> exporter instance's entire buffer. A barrier may therefore publish records
77+
> from another execution that were already buffered, while a record scheduled
78+
> after that barrier is exported after the flush and waits for a later barrier.
79+
> A custom batching exporter that requires execution-level isolation should key
80+
> its buffer by `executionArn` or use a distinct exporter instance per isolated
81+
> stream.
7282
7383
## Requirements
7484

packages/aws-durable-execution-sdk-python-insight/src/aws_durable_execution_sdk_python_insight/_export_scheduler.py

Lines changed: 103 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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
66
The plugin builds one canonical ``WorkflowInsight`` record on the SDK checkpoint
77
thread and hands it to :class:`_ExportScheduler`. The scheduler keeps all
@@ -16,12 +16,12 @@
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
@@ -54,6 +54,17 @@
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

Comments
 (0)