Skip to content

Commit 6f05178

Browse files
author
Alex Wang
committed
fix(insight): preserve timed-out FIFO exports
1 parent 5c3a752 commit 6f05178

2 files changed

Lines changed: 137 additions & 68 deletions

File tree

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

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
_DEFAULT_MAX_PENDING_BYTES = 16_000_000
5757

5858

59-
def _estimate_retained_size(value: Any) -> int:
59+
def _estimate_retained_size(value: Any, max_size: int | None = None) -> int:
6060
"""Estimate retained Python memory without serializing or calling render()."""
6161
total = 0
6262
seen: set[int] = set()
@@ -71,8 +71,12 @@ def _estimate_retained_size(value: Any) -> int:
7171
total += sys.getsizeof(item)
7272
except Exception: # noqa: BLE001 - estimation must never break a hook
7373
total += 1_024
74+
if max_size is not None and total > max_size:
75+
return max_size + 1
7476
try:
75-
if isinstance(item, dict):
77+
if isinstance(item, memoryview):
78+
stack.append(item.obj)
79+
elif isinstance(item, dict):
7680
stack.extend(item.keys())
7781
stack.extend(item.values())
7882
elif isinstance(item, (list, tuple, set, frozenset, deque)):
@@ -285,26 +289,19 @@ def request_stop_when_idle(self) -> None:
285289
self._cond.notify()
286290

287291
def cancel_flush(self, barrier: _FlushBarrier) -> None:
288-
"""Cancel a timed-out flush barrier so it cannot pile up behind a
289-
blocked worker.
290-
291-
Under the lane lock: mark the barrier cancelled and, if its ``_FLUSH``
292-
marker is still queued, remove that exact marker and complete the
293-
barrier here. Removing it is what keeps queue/barrier state bounded
294-
across many warm invocations behind a blocked exporter -- otherwise one
295-
stale barrier per invocation would accumulate behind the stuck worker.
296-
297-
If the worker has already popped the marker (the flush is in flight or
298-
about to run) the marker is no longer in the queue: we only set
299-
``canceled`` and leave completion to the worker, which skips the
300-
now-pointless flush and completes the barrier itself. A synchronous
301-
in-flight ``flush()`` is never interrupted.
302-
"""
292+
"""Stop waiting for a timed-out barrier while retaining one later flush."""
303293
with self._cond:
304294
barrier.canceled = True
295+
# Keep at most one detached flush. Moving it to this barrier's
296+
# position makes it cover all work scheduled before the latest
297+
# timeout without accumulating one marker per warm invocation.
298+
for index in range(len(self._queue) - 1, -1, -1):
299+
kind, payload = self._queue[index]
300+
if kind == _FLUSH and payload is None:
301+
del self._queue[index]
305302
for index, (kind, payload) in enumerate(self._queue):
306303
if kind == _FLUSH and payload is barrier:
307-
del self._queue[index]
304+
self._queue[index] = (_FLUSH, None)
308305
barrier.complete()
309306
return
310307

@@ -407,7 +404,7 @@ def _disable_locked(self, exc: Exception) -> None:
407404
self._pending.clear()
408405
self._pending_bytes = 0
409406
for kind, payload in self._queue:
410-
if kind == _FLUSH:
407+
if kind == _FLUSH and payload is not None:
411408
barrier: _FlushBarrier = payload
412409
barrier.canceled = True
413410
barrier.failed = True
@@ -472,10 +469,10 @@ def _run_worker(self) -> None:
472469
if kind == _RECORD and record is not None:
473470
self._export_one(record)
474471
else: # _FLUSH
475-
barrier: _FlushBarrier = payload
476-
if not barrier.canceled:
477-
self._flush()
478-
barrier.complete()
472+
barrier: _FlushBarrier | None = payload
473+
self._flush()
474+
if barrier is not None:
475+
barrier.complete()
479476

480477
def _export_one(self, record: dict[str, Any]) -> None:
481478
exporter = self._exporter
@@ -564,20 +561,21 @@ def __init__(
564561
),
565562
max_pending_bytes: int = _DEFAULT_MAX_PENDING_BYTES,
566563
) -> None:
564+
self._max_pending_bytes = max(1, max_pending_bytes)
567565
self._lanes = [
568566
_ExporterLane(
569567
exporter,
570568
max_pending_executions=max_pending_executions,
571569
max_pending_records=max_pending_records,
572570
max_pending_records_per_execution=max_pending_records_per_execution,
573-
max_pending_bytes=max_pending_bytes,
571+
max_pending_bytes=self._max_pending_bytes,
574572
)
575573
for exporter in exporters
576574
]
577575

578576
def schedule(self, execution_arn: str, record: dict[str, Any]) -> None:
579577
"""Fan a canonical record out to every lane. Returns immediately."""
580-
record_size = _estimate_retained_size(record)
578+
record_size = _estimate_retained_size(record, self._max_pending_bytes)
581579
for lane in self._lanes:
582580
lane.schedule(execution_arn, record, record_size)
583581

packages/aws-durable-execution-sdk-python-insight/tests/test_export_scheduler.py

Lines changed: 114 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,35 @@ def release(self) -> None:
151151
self._release.set()
152152

153153

154+
class BlockingBufferedExporter:
155+
"""Blocks export and publishes buffered records only when flush runs."""
156+
157+
max_record_size_bytes = None
158+
159+
def __init__(self) -> None:
160+
self.started = threading.Event()
161+
self._release = threading.Event()
162+
self.buffered: list[Any] = []
163+
self.published: list[Any] = []
164+
self.flushed = 0
165+
166+
def render(self, record: dict[str, Any]) -> Any:
167+
return record
168+
169+
def export(self, record: dict[str, Any]) -> None:
170+
self.started.set()
171+
self._release.wait(5.0)
172+
self.buffered.append(record.get("v"))
173+
174+
def flush(self) -> None:
175+
self.flushed += 1
176+
self.published.extend(self.buffered)
177+
self.buffered.clear()
178+
179+
def release(self) -> None:
180+
self._release.set()
181+
182+
154183
class FailingExporter:
155184
"""Raises in both export and flush."""
156185

@@ -200,6 +229,16 @@ def __sizeof__(self) -> int:
200229
raise RuntimeError("size unavailable")
201230

202231

232+
class _TrackedLargeList(list[Any]):
233+
def __init__(self) -> None:
234+
super().__init__([None] * 10_000)
235+
self.iterated = False
236+
237+
def __iter__(self):
238+
self.iterated = True
239+
return super().__iter__()
240+
241+
203242
# -- lazy worker creation / one worker per exporter --------------------------
204243

205244

@@ -731,6 +770,42 @@ def test_retained_size_traverses_slots_after_shallow_size_failure():
731770
assert exporter.exported_values() == ["inflight"]
732771

733772

773+
def test_retained_size_saturates_before_traversing_large_shallow_container():
774+
exporter = BlockingExporter()
775+
scheduler = _ExportScheduler([exporter], max_pending_bytes=2_500)
776+
lane = scheduler._lanes[0]
777+
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
778+
assert _wait_until(exporter.started.is_set)
779+
780+
payload = _TrackedLargeList()
781+
record = _rec(ARN_B, "large-shallow")
782+
record["payload"] = payload
783+
scheduler.schedule(ARN_B, record)
784+
785+
assert payload.iterated is False
786+
assert lane._pending_count() == 0
787+
assert lane._pending_bytes_count() == 0
788+
exporter.release()
789+
scheduler.end_invocation(5.0)
790+
791+
792+
def test_retained_size_counts_memoryview_backing_buffer():
793+
exporter = BlockingExporter()
794+
scheduler = _ExportScheduler([exporter], max_pending_bytes=2_500)
795+
lane = scheduler._lanes[0]
796+
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
797+
assert _wait_until(exporter.started.is_set)
798+
799+
record = _rec(ARN_B, "memoryview")
800+
record["payload"] = memoryview(bytearray(4_000))
801+
scheduler.schedule(ARN_B, record)
802+
803+
assert lane._pending_count() == 0
804+
assert lane._pending_bytes_count() == 0
805+
exporter.release()
806+
scheduler.end_invocation(5.0)
807+
808+
734809
def test_record_sizing_exception_does_not_escape_schedule():
735810
exporter = BlockingExporter()
736811
scheduler = _ExportScheduler([exporter])
@@ -809,97 +884,93 @@ def test_cancelled_barrier_preserves_generation_order_and_terminal():
809884
assert lane._queue_len() == 0
810885

811886

812-
def test_cancelled_barrier_is_cleaned_up_and_worker_exits():
887+
def test_timed_out_barrier_flushes_eventually_and_worker_exits():
813888
exporter = BlockingExporter()
814889
scheduler = _ExportScheduler([exporter])
815890
lane = scheduler._lanes[0]
816891
scheduler.schedule(ARN_A, _rec(ARN_A, "a1"))
817892
assert _wait_until(exporter.started.is_set)
818-
ok = scheduler.end_invocation(0.1) # times out -> barrier cancelled
893+
ok = scheduler.end_invocation(0.1)
819894
assert ok is False
820-
# Once the exporter unblocks, the worker drains the cancelled barrier
821-
# (skipping the pointless flush) and exits idle -- no permanent leak.
895+
# The caller returns on time, but one detached flush remains queued so a
896+
# buffered exporter can publish before the worker exits idle.
897+
assert lane._queued_flush_count() == 1
822898
exporter.release()
823899
assert _wait_until(lambda: not lane._worker_alive())
824-
assert exporter.flushed == 0 # cancelled barrier did not flush
900+
assert exporter.flushed == 1
901+
902+
903+
def test_timed_out_buffered_export_is_published_eventually():
904+
exporter = BlockingBufferedExporter()
905+
scheduler = _ExportScheduler([exporter])
906+
lane = scheduler._lanes[0]
907+
scheduler.schedule(ARN_A, _rec(ARN_A, "terminal", status="SUCCEEDED"))
908+
assert _wait_until(exporter.started.is_set)
909+
910+
assert scheduler.end_invocation(0.1) is False
911+
assert exporter.published == []
912+
exporter.release()
913+
914+
assert _wait_until(lambda: not lane._worker_alive())
915+
assert exporter.published == ["terminal"]
916+
assert exporter.flushed == 1
825917

826918

827919
def test_repeated_timeouts_behind_blocked_exporter_stay_bounded():
828-
"""A blocked exporter across many warm invocations must not accumulate
829-
barriers or grow queue state, must keep the SAME worker (no replacement),
830-
must not execute any cancelled flush, and must drain + exit after release.
831-
"""
920+
"""Warm timeouts coalesce to one eventual flush on the same worker."""
832921
exporter = BlockingExporter()
833922
scheduler = _ExportScheduler([exporter])
834923
lane = scheduler._lanes[0]
835924

836-
# First record puts the single worker into a blocked export.
837925
scheduler.schedule(ARN_A, _rec(ARN_A, "a1"))
838926
assert _wait_until(exporter.started.is_set)
839927
worker = lane._worker
840928
assert worker is not None and worker.is_alive()
841929

842-
# Many warm invocations. Each schedules a record for the same ARN then ends
843-
# with a short timeout; the barrier always times out because the worker is
844-
# still stuck in the first export.
845930
for i in range(50):
846931
scheduler.schedule(ARN_A, _rec(ARN_A, f"a{i + 2}"))
847-
ok = scheduler.end_invocation(0.02)
848-
assert ok is False # degraded every time -- worker is blocked
849-
# The cancelled barrier is pulled from the queue immediately, so no
850-
# _FLUSH marker lingers behind the blocked worker.
851-
assert lane._queued_flush_count() == 0
852-
# Each cancelled barrier can leave one generation token, but both
853-
# records and tokens remain bounded by the per-execution FIFO depth.
854-
assert lane._queue_len() <= 16
932+
assert scheduler.end_invocation(0.02) is False
933+
# At most 16 generation tokens plus one detached eventual flush.
934+
assert lane._queued_flush_count() == 1
935+
assert lane._queue_len() <= 17
855936
assert lane._pending_record_count() <= 16
856937

857-
# Bounded state: one in-flight ARN with a bounded pending FIFO and bounded
858-
# generation tokens, with no growing pile of barriers.
859-
assert lane._queue_len() <= 16
938+
assert lane._queue_len() <= 17
860939
assert lane._pending_count() <= 1
861940
assert lane._pending_record_count() <= 16
862-
assert lane._queued_flush_count() == 0
863-
# The blocked worker was never replaced.
941+
assert lane._queued_flush_count() == 1
864942
assert lane._worker is worker
865943
assert worker.is_alive()
866944
assert _lane_worker_count(lane) == 1
867-
# No cancelled flush ran while the worker was blocked.
868945
assert exporter.flushed == 0
869946

870-
# Release: the worker drains the newest bounded window, then exits idle.
871947
exporter.release()
872948
assert _wait_until(lambda: not lane._worker_alive())
873949
exported = exporter.exported_values()
874-
assert exported[0] == "a1" # the in-flight record delivered first
875-
assert len(exported) <= 17 # a1 plus at most 16 pending FIFO records
876-
assert exported[-1] == "a51" # newest snapshot was retained
877-
# Cancelled barriers never triggered a flush, and the idle-stop path does
878-
# not flush either.
879-
assert exporter.flushed == 0
950+
assert exported[0] == "a1"
951+
assert len(exported) <= 17
952+
assert exported[-1] == "a51"
953+
assert exporter.flushed == 1
880954

881955

882-
def test_cancel_flush_removes_queued_barrier_immediately():
883-
"""Queued-barrier race: while the worker is blocked the barrier is still in
884-
the queue, so cancel_flush pulls it out and completes it synchronously --
885-
without waiting for the worker and without ever flushing."""
956+
def test_cancel_flush_replaces_queued_barrier_with_detached_flush():
886957
exporter = BlockingExporter()
887958
scheduler = _ExportScheduler([exporter])
888959
lane = scheduler._lanes[0]
889960
scheduler.schedule(ARN_A, _rec(ARN_A, "a1"))
890-
assert _wait_until(exporter.started.is_set) # worker blocked in export
961+
assert _wait_until(exporter.started.is_set)
891962
barrier = lane.enqueue_flush()
892963
assert lane._queued_flush_count() == 1
964+
893965
lane.cancel_flush(barrier)
894-
# Removed from the queue and completed here, without the worker.
895-
assert lane._queued_flush_count() == 0
966+
967+
assert lane._queued_flush_count() == 1
896968
assert barrier.canceled is True
897969
assert barrier.is_done()
898-
# Finish the in-flight export and go idle; the pulled barrier never flushed.
899970
exporter.release()
900971
lane.request_stop_when_idle()
901972
assert _wait_until(lambda: not lane._worker_alive())
902-
assert exporter.flushed == 0
973+
assert exporter.flushed == 1
903974
assert exporter.exported_values() == ["a1"]
904975

905976

0 commit comments

Comments
 (0)