Skip to content

Commit 687d450

Browse files
author
Alex Wang
committed
fix(insight): preserve bounded FIFO records
1 parent 12ba567 commit 687d450

2 files changed

Lines changed: 137 additions & 61 deletions

File tree

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

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,54 @@ 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+
try:
75+
if isinstance(item, dict):
76+
stack.extend(item.keys())
77+
stack.extend(item.values())
78+
elif isinstance(item, (list, tuple, set, frozenset, deque)):
79+
stack.extend(item)
80+
else:
81+
try:
82+
stack.append(vars(item))
83+
except Exception: # noqa: BLE001 - custom objects may use slots
84+
pass
85+
for cls in type(item).__mro__:
86+
slots = vars(cls).get("__slots__", ())
87+
if isinstance(slots, str):
88+
slots = (slots,)
89+
for slot in slots:
90+
if slot in {"__dict__", "__weakref__"}:
91+
continue
92+
if slot.startswith("__") and not slot.endswith("__"):
93+
slot = f"_{cls.__name__.lstrip('_')}{slot}"
94+
try:
95+
stack.append(getattr(item, slot))
96+
except Exception: # noqa: BLE001 - unset/custom slots are best-effort
97+
pass
98+
except Exception: # noqa: BLE001 - traversal must never break a hook
99+
pass
100+
return total
101+
102+
103+
def _copy_record_containers(record: dict[str, Any]) -> dict[str, Any]:
104+
"""Copy built-in containers while treating custom values as opaque leaves."""
105+
memo: dict[int, Any] = {}
106+
seen: set[int] = set()
107+
stack: list[Any] = [record]
108+
while stack:
109+
item = stack.pop()
110+
identity = id(item)
111+
if identity in seen:
74112
continue
75-
if isinstance(item, dict):
113+
seen.add(identity)
114+
if type(item) is dict:
76115
stack.extend(item.keys())
77116
stack.extend(item.values())
78-
elif isinstance(item, (list, tuple, set, frozenset, deque)):
117+
elif type(item) in {list, tuple, set, frozenset, deque}:
79118
stack.extend(item)
80119
else:
81-
try:
82-
stack.append(vars(item))
83-
except Exception: # noqa: BLE001 - custom objects are best-effort
84-
pass
85-
return total
120+
memo[identity] = item
121+
return copy.deepcopy(record, memo)
86122

87123

88124
# Queue entry kinds.
@@ -183,13 +219,17 @@ def schedule(
183219
self._stop_when_idle = False
184220
size = max(0, record_size)
185221
if size > self._max_pending_bytes:
222+
superseded = execution_arn in self._pending
223+
if superseded:
224+
self._drop_pending_execution(execution_arn)
186225
_logger.warning(
187226
"workflow-insight: pending record for %s on %s exceeds the "
188-
"byte budget (%d > %d); dropping this record",
227+
"byte budget (%d > %d); dropping this record%s",
189228
execution_arn,
190229
type(self._exporter).__name__,
191230
size,
192231
self._max_pending_bytes,
232+
" and its superseded pending FIFO" if superseded else "",
193233
)
194234
return
195235
pending = self._pending.get(execution_arn)
@@ -439,18 +479,15 @@ def _run_worker(self) -> None:
439479

440480
def _export_one(self, record: dict[str, Any]) -> None:
441481
exporter = self._exporter
442-
# Copy for exporter isolation: every lane shares the same canonical
443-
# record, and truncation/export must never mutate what another lane
444-
# sees. If the copy fails we must NOT fall back to the shared record --
445-
# exporting the alias would let this lane's truncation mutate the object
446-
# other lanes still read, breaking workflow isolation. Treat a copy
447-
# failure like a render/truncation failure: log and skip this record for
448-
# this lane, then continue processing the lane's queue.
482+
# Copy the record's built-in containers for lane isolation, but preserve
483+
# custom values as opaque leaves for exporter-specific rendering. This
484+
# keeps one lane's render/truncation mutations out of other lanes without
485+
# requiring custom-renderable values to implement ``deepcopy``.
449486
try:
450-
local = copy.deepcopy(record)
451-
except Exception as exc: # noqa: BLE001 - a non-copyable payload must not alias the shared record or break the lane
487+
local = _copy_record_containers(record)
488+
except Exception as exc: # noqa: BLE001 - malformed containers must not break the lane
452489
_logger.warning(
453-
"workflow-insight: record copy failed for exporter %s; "
490+
"workflow-insight: record container copy failed for exporter %s; "
454491
"skipping export for this record: %s",
455492
type(exporter).__name__,
456493
exc,

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

Lines changed: 82 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
from __future__ import annotations
1313

14-
import logging
1514
import threading
1615
import time
1716
from typing import Any
@@ -182,6 +181,18 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Any:
182181
raise RuntimeError("uncopyable payload")
183182

184183

184+
class _SlottedPayload:
185+
__slots__ = ("payload",)
186+
187+
def __init__(self, payload: Any) -> None:
188+
self.payload = payload
189+
190+
191+
class _UnsizedSlottedPayload(_SlottedPayload):
192+
def __sizeof__(self) -> int:
193+
raise RuntimeError("size unavailable")
194+
195+
185196
class _Unsized:
186197
"""A payload whose custom ``__sizeof__`` raises."""
187198

@@ -318,58 +329,45 @@ def test_pending_fifo_cap_drops_oldest_and_retains_terminal():
318329
# -- copy failure isolation ---------------------------------------------------
319330

320331

321-
def test_deepcopy_failure_skips_record_and_lane_continues(caplog):
322-
exporter = RecordingExporter()
332+
def test_uncopyable_custom_value_reaches_exporter_render():
333+
class CustomRenderExporter(RecordingExporter):
334+
def __init__(self) -> None:
335+
super().__init__(max_record_size_bytes=10_000)
336+
self.rendered_values: list[str] = []
337+
338+
def render(self, record: dict[str, Any]) -> Any:
339+
value = record["payload"]["value"]
340+
self.rendered_values.append(value)
341+
return {"value": value}
342+
343+
exporter = CustomRenderExporter()
323344
scheduler = _ExportScheduler([exporter])
324-
# A record whose deepcopy raises must be skipped for this lane -- never
325-
# exported by aliasing the shared object -- and the lane must keep draining.
326-
bad = _rec(ARN_A, "bad")
327-
bad["payload"] = _Uncopyable()
328-
good = _rec(ARN_B, "good")
329-
with caplog.at_level(
330-
logging.WARNING, logger="aws_durable_execution_sdk_python_insight"
331-
):
332-
scheduler.schedule(ARN_A, bad) # queued first: copy fails -> skipped
333-
scheduler.schedule(ARN_B, good) # queued behind it: must still export
334-
# The good record delivering proves the lane continued past the failure;
335-
# a single-lane worker drains FIFO, so "bad" was processed (and skipped)
336-
# before "good" ran.
337-
assert _wait_until(lambda: exporter.exported_values() == ["good"])
338-
scheduler.end_invocation(5.0)
339-
# The exporter was never called for the un-copyable record.
340-
assert exporter.exported_values() == ["good"]
341-
# The failure was logged through the module logger.
342-
assert any(
343-
"record copy failed" in record.getMessage()
344-
for record in caplog.records
345-
if record.name == "aws_durable_execution_sdk_python_insight"
346-
)
345+
record = _rec(ARN_A, "before-render")
346+
record["payload"] = _Uncopyable()
347347

348+
scheduler.schedule(ARN_A, record)
349+
scheduler.end_invocation(5.0)
350+
351+
assert exporter.rendered_values == ["safe"]
352+
assert exporter.exported_values() == ["before-render"]
348353

349-
def test_deepcopy_failure_does_not_alias_shared_record():
350-
# Before the fix a copy failure aliased the shared record and passed it to
351-
# truncate_record -> render, which could mutate the canonical object other
352-
# lanes still read. With the fix the record is skipped before render, so it
353-
# is never aliased or mutated in place.
354+
355+
def test_uncopyable_custom_value_does_not_alias_record_containers():
354356
class MutatingRenderExporter(RecordingExporter):
355357
def render(self, record: dict[str, Any]) -> Any:
356-
record["mutated"] = True # would corrupt an aliased shared record
358+
record["mutated"] = True
357359
return record
358360

359361
exporter = MutatingRenderExporter()
360362
scheduler = _ExportScheduler([exporter])
361-
bad = _rec(ARN_A, "bad")
362-
bad["payload"] = _Uncopyable()
363-
scheduler.schedule(ARN_A, bad)
364-
# A good record behind it lets us deterministically wait for the lane to
365-
# drain past the bad one (single lane drains FIFO).
366-
scheduler.schedule(ARN_B, _rec(ARN_B, "good"))
367-
assert _wait_until(lambda: exporter.exported_values() == ["good"])
363+
record = _rec(ARN_A, "custom")
364+
record["payload"] = _Uncopyable()
365+
366+
scheduler.schedule(ARN_A, record)
368367
scheduler.end_invocation(5.0)
369-
# render never ran on the un-copyable record, so the canonical object was
370-
# neither aliased into export nor mutated in place.
371-
assert "mutated" not in bad
372-
assert exporter.exported_values() == ["good"]
368+
369+
assert "mutated" not in record
370+
assert exporter.exported_values() == ["custom"]
373371

374372

375373
# -- non-blocking hook return / fast-vs-slow isolation -----------------------
@@ -692,6 +690,47 @@ def test_individually_over_budget_record_does_not_evict_existing_backlog():
692690
assert exported[1:] == ["b" * 700, "c" * 700]
693691

694692

693+
def test_over_budget_replacement_removes_superseded_same_arn_only():
694+
exporter = BlockingExporter()
695+
scheduler = _ExportScheduler([exporter], max_pending_bytes=3_500)
696+
lane = scheduler._lanes[0]
697+
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
698+
assert _wait_until(exporter.started.is_set)
699+
scheduler.schedule(ARN_A, _rec(ARN_A, "stale-running"))
700+
scheduler.schedule(ARN_B, _rec(ARN_B, "unrelated"))
701+
scheduler.schedule(
702+
ARN_A,
703+
_rec(ARN_A, "terminal" * 500, status="SUCCEEDED"),
704+
)
705+
706+
assert lane._pending_count() == 1
707+
assert lane._pending_bytes_count() <= 3_500
708+
exporter.release()
709+
scheduler.end_invocation(5.0)
710+
assert exporter.exported_values() == ["inflight", "unrelated"]
711+
712+
713+
def test_retained_size_traverses_slots_after_shallow_size_failure():
714+
for payload in (
715+
_SlottedPayload("x" * 4_000),
716+
_UnsizedSlottedPayload("x" * 4_000),
717+
):
718+
exporter = BlockingExporter()
719+
scheduler = _ExportScheduler([exporter], max_pending_bytes=2_500)
720+
lane = scheduler._lanes[0]
721+
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
722+
assert _wait_until(exporter.started.is_set)
723+
record = _rec(ARN_B, "opaque")
724+
record["payload"] = payload
725+
scheduler.schedule(ARN_B, record)
726+
727+
assert lane._pending_count() == 0
728+
assert lane._pending_bytes_count() == 0
729+
exporter.release()
730+
scheduler.end_invocation(5.0)
731+
assert exporter.exported_values() == ["inflight"]
732+
733+
695734
def test_record_sizing_exception_does_not_escape_schedule():
696735
exporter = BlockingExporter()
697736
scheduler = _ExportScheduler([exporter])

0 commit comments

Comments
 (0)