Skip to content

Commit d876e48

Browse files
author
Alex Wang
committed
fix(insight): bound FIFO in-flight retention
1 parent 6dfa457 commit d876e48

2 files changed

Lines changed: 123 additions & 26 deletions

File tree

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

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import functools
2626
import itertools
2727
import logging
28-
import sys
2928
import threading
3029
import time
3130
import types
@@ -73,7 +72,6 @@
7372
bool,
7473
type(None),
7574
range,
76-
slice,
7775
type,
7876
types.ModuleType,
7977
types.CodeType,
@@ -135,6 +133,8 @@ def _retained_children(value: Any) -> Any:
135133
return itertools.chain(frozenset.__iter__(value), custom)
136134
if isinstance(value, deque):
137135
return itertools.chain(deque.__iter__(value), custom)
136+
if isinstance(value, slice):
137+
return itertools.chain((value.start, value.stop, value.step), custom)
138138
if isinstance(value, memoryview):
139139
return itertools.chain((value.obj,), custom)
140140
if isinstance(value, functools.partial):
@@ -175,26 +175,25 @@ def _retained_children(value: Any) -> Any:
175175

176176

177177
def _retained_shallow_size(value: Any) -> int:
178-
try:
179-
size = sys.getsizeof(value)
180-
except Exception: # noqa: BLE001 - estimation must never break a hook
181-
size = 1_024
178+
"""Return shallow size without dispatching to user-defined ``__sizeof__``."""
182179
try:
183180
if isinstance(value, dict):
184-
size = max(size, dict.__sizeof__(value))
185-
elif isinstance(value, list):
186-
size = max(size, list.__sizeof__(value))
187-
elif isinstance(value, tuple):
188-
size = max(size, tuple.__sizeof__(value))
189-
elif isinstance(value, set):
190-
size = max(size, set.__sizeof__(value))
191-
elif isinstance(value, frozenset):
192-
size = max(size, frozenset.__sizeof__(value))
193-
elif isinstance(value, deque):
194-
size = max(size, deque.__sizeof__(value))
195-
except Exception: # noqa: BLE001 - base sizing remains best-effort
196-
pass
197-
return size
181+
return dict.__sizeof__(value)
182+
if isinstance(value, list):
183+
return list.__sizeof__(value)
184+
if isinstance(value, tuple):
185+
return tuple.__sizeof__(value)
186+
if isinstance(value, set):
187+
return set.__sizeof__(value)
188+
if isinstance(value, frozenset):
189+
return frozenset.__sizeof__(value)
190+
if isinstance(value, deque):
191+
return deque.__sizeof__(value)
192+
if type(value) in _ATOMIC_RETAINED_TYPES + _SAFE_OPAQUE_RETAINED_TYPES:
193+
return value.__sizeof__()
194+
return object.__sizeof__(value)
195+
except Exception: # noqa: BLE001 - estimation must never break a hook
196+
return 1_024
198197

199198

200199
def _estimate_retained_size(value: Any, max_size: int | None = None) -> int:
@@ -313,6 +312,7 @@ def __init__(
313312
self._max_pending_per_execution = max(1, max_pending_records_per_execution)
314313
self._max_pending_bytes = max(1, max_pending_bytes)
315314
self._pending_bytes = 0
315+
self._inflight_bytes = 0
316316
# Explicit non-reentrant Lock rather than Condition()'s default RLock:
317317
# the lane never re-acquires ``_cond`` while already holding it (worker
318318
# I/O -- export/flush -- runs outside the lock and no locked helper
@@ -412,6 +412,12 @@ def cancel_flush(self, barrier: _FlushBarrier) -> None:
412412
"""Stop waiting for a timed-out barrier while retaining one later flush."""
413413
with self._cond:
414414
barrier.canceled = True
415+
if not any(
416+
kind == _FLUSH and payload is barrier for kind, payload in self._queue
417+
):
418+
# The worker already owns this barrier. Do not erase a detached
419+
# flush installed by a later invocation while this one was in flight.
420+
return
415421
# Keep at most one detached flush. Moving it to this barrier's
416422
# position makes it cover all work scheduled before the latest
417423
# timeout without accumulating one marker per warm invocation.
@@ -481,7 +487,10 @@ def _enforce_pending_record_cap(self) -> None:
481487
)
482488

483489
def _enforce_pending_byte_cap(self) -> None:
484-
while self._pending_bytes > self._max_pending_bytes and self._pending:
490+
while (
491+
self._pending_bytes + self._inflight_bytes > self._max_pending_bytes
492+
and self._pending
493+
):
485494
old_arn = self._oldest_pending_arn()
486495
dropped_size = self._drop_oldest_pending_record(old_arn)
487496
_logger.warning(
@@ -523,6 +532,7 @@ def _disable_locked(self, exc: Exception) -> None:
523532
self._worker = None
524533
self._pending.clear()
525534
self._pending_bytes = 0
535+
self._inflight_bytes = 0
526536
for kind, payload in self._queue:
527537
if kind == _FLUSH and payload is not None:
528538
barrier: _FlushBarrier = payload
@@ -571,14 +581,17 @@ def _run_worker(self) -> None:
571581
return
572582
kind, payload = self._queue.popleft()
573583
record: dict[str, Any] | None = None
584+
record_size = 0
574585
if kind == _RECORD:
575586
token: _RecordToken = payload
576587
execution_arn, generation = token
577588
pending = self._pending.get(execution_arn)
578589
if not pending or pending[0].generation != generation:
579590
continue
580591
pending_record = pending.popleft()
581-
self._pending_bytes -= pending_record.size
592+
record_size = pending_record.size
593+
self._pending_bytes -= record_size
594+
self._inflight_bytes += record_size
582595
record = pending_record.value
583596
if pending and pending[0].generation == generation:
584597
# One record per ARN turn within this barrier generation.
@@ -587,7 +600,11 @@ def _run_worker(self) -> None:
587600
del self._pending[execution_arn]
588601

589602
if kind == _RECORD and record is not None:
590-
self._export_one(record)
603+
try:
604+
self._export_one(record)
605+
finally:
606+
with self._cond:
607+
self._inflight_bytes -= record_size
591608
else: # _FLUSH
592609
barrier: _FlushBarrier | None = payload
593610
self._flush()
@@ -658,6 +675,10 @@ def _pending_bytes_count(self) -> int:
658675
with self._cond:
659676
return self._pending_bytes
660677

678+
def _retained_bytes_count(self) -> int:
679+
with self._cond:
680+
return self._pending_bytes + self._inflight_bytes
681+
661682
def _queue_len(self) -> int:
662683
with self._cond:
663684
return len(self._queue)

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

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -720,16 +720,16 @@ def test_non_json_record_reaches_exporter_without_evicting_backlog():
720720

721721
def test_individually_over_budget_record_does_not_evict_existing_backlog():
722722
exporter = BlockingExporter()
723-
scheduler = _ExportScheduler([exporter], max_pending_bytes=3_000)
723+
scheduler = _ExportScheduler([exporter], max_pending_bytes=5_000)
724724
lane = scheduler._lanes[0]
725725
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
726726
assert _wait_until(exporter.started.is_set)
727727
scheduler.schedule(ARN_B, _rec(ARN_B, "b" * 700))
728728
scheduler.schedule(ARN_C, _rec(ARN_C, "c" * 700))
729-
scheduler.schedule(ARN_D, _rec(ARN_D, "d" * 3_000))
729+
scheduler.schedule(ARN_D, _rec(ARN_D, "d" * 5_000))
730730

731731
assert lane._pending_count() == 2
732-
assert lane._pending_bytes_count() <= 3_000
732+
assert lane._pending_bytes_count() <= 5_000
733733
exporter.release()
734734
scheduler.end_invocation(5.0)
735735
exported = exporter.exported_values()
@@ -1114,3 +1114,79 @@ def test_cancel_flush_after_pop_lets_worker_complete_barrier():
11141114
assert exporter.calls.count(("flush", None)) == 1
11151115
lane.request_stop_when_idle()
11161116
assert _wait_until(lambda: not lane._worker_alive())
1117+
1118+
1119+
def test_retained_size_counts_slice_referents():
1120+
exporter = BlockingExporter()
1121+
scheduler = _ExportScheduler([exporter], max_pending_bytes=2_500)
1122+
lane = scheduler._lanes[0]
1123+
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
1124+
assert _wait_until(exporter.started.is_set)
1125+
1126+
record = _rec(ARN_B, "slice")
1127+
record["payload"] = slice(bytearray(4_000), None)
1128+
scheduler.schedule(ARN_B, record)
1129+
1130+
assert lane._pending_count() == 0
1131+
assert lane._pending_bytes_count() == 0
1132+
exporter.release()
1133+
scheduler.end_invocation(5.0)
1134+
1135+
1136+
def test_retained_size_does_not_dispatch_custom_sizeof():
1137+
called = threading.Event()
1138+
1139+
class CustomSized:
1140+
def __sizeof__(self) -> int:
1141+
called.set()
1142+
raise AssertionError("custom __sizeof__ must not run")
1143+
1144+
exporter = RecordingExporter()
1145+
scheduler = _ExportScheduler([exporter])
1146+
record = _rec(ARN_A, "custom-sized")
1147+
record["payload"] = CustomSized()
1148+
1149+
scheduler.schedule(ARN_A, record)
1150+
scheduler.end_invocation(5.0)
1151+
1152+
assert called.is_set() is False
1153+
assert exporter.exported_values() == ["custom-sized"]
1154+
1155+
1156+
def test_inflight_record_remains_charged_until_export_returns():
1157+
exporter = BlockingExporter()
1158+
scheduler = _ExportScheduler([exporter], max_pending_bytes=3_000)
1159+
lane = scheduler._lanes[0]
1160+
scheduler.schedule(ARN_A, _rec(ARN_A, "a" * 1_500))
1161+
assert _wait_until(exporter.started.is_set)
1162+
1163+
scheduler.schedule(ARN_B, _rec(ARN_B, "b" * 1_500))
1164+
1165+
assert lane._pending_count() == 0
1166+
assert lane._retained_bytes_count() <= 3_000
1167+
exporter.release()
1168+
scheduler.end_invocation(5.0)
1169+
assert exporter.exported_values() == ["a" * 1_500]
1170+
assert lane._retained_bytes_count() == 0
1171+
1172+
1173+
def test_cancel_popped_barrier_preserves_later_detached_flush():
1174+
exporter = BlockingFlushExporter()
1175+
scheduler = _ExportScheduler([exporter])
1176+
lane = scheduler._lanes[0]
1177+
scheduler.schedule(ARN_A, _rec(ARN_A, "a1"))
1178+
older = lane.enqueue_flush()
1179+
assert _wait_until(exporter.flush_started.is_set)
1180+
1181+
later = lane.enqueue_flush()
1182+
lane.cancel_flush(later)
1183+
assert lane._queued_flush_count() == 1
1184+
lane.cancel_flush(older)
1185+
1186+
assert lane._queued_flush_count() == 1
1187+
assert later.is_done()
1188+
exporter.release_flush()
1189+
assert _wait_until(older.is_done)
1190+
lane.request_stop_when_idle()
1191+
assert _wait_until(lambda: not lane._worker_alive())
1192+
assert exporter.calls.count(("flush", None)) == 2

0 commit comments

Comments
 (0)