Skip to content

Commit 12ba567

Browse files
author
Alex Wang
committed
fix(insight): preserve shaped record admission
1 parent c24fdea commit 12ba567

3 files changed

Lines changed: 115 additions & 36 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Behavior is validated cross-SDK by the `insight` conformance suite
6363
> `WorkflowInsightPlugin`: listing it twice or sharing it across plugin instances
6464
> raises `ValueError`. Separate instances of the same exporter class are fine.
6565
> Each lane keeps up to 16 pending snapshots per execution, 1,024 records total,
66-
> and 16 MB of estimated canonical JSON. When a bound fills, it drops the oldest
66+
> and 16 MB of estimated retained memory. When a bound fills, it drops the oldest
6767
> pending snapshot so recent progress and terminal snapshots are retained. At
6868
> invocation end the plugin drains and flushes the touched exporters under a
6969
> single shared deadline

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

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,14 @@
2121

2222
import copy
2323
import logging
24+
import sys
2425
import threading
2526
import time
2627
from collections import deque
2728
from dataclasses import dataclass
2829
from typing import Any
2930

30-
from aws_durable_execution_sdk_python_insight.truncation import (
31-
json_byte_size,
32-
truncate_record,
33-
)
31+
from aws_durable_execution_sdk_python_insight.truncation import truncate_record
3432
from aws_durable_execution_sdk_python_insight.types import InsightExporter
3533

3634

@@ -53,10 +51,40 @@
5351
# exporter. The in-flight record is not included in this count.
5452
_DEFAULT_MAX_PENDING_RECORDS_PER_EXECUTION = 16
5553

56-
# Canonical JSON-byte estimate retained by one blocked lane. This keeps the
54+
# Estimated Python object memory retained by one blocked lane. This keeps the
5755
# instrumentation backlog well below Lambda's 128 MiB memory floor.
5856
_DEFAULT_MAX_PENDING_BYTES = 16_000_000
5957

58+
59+
def _estimate_retained_size(value: Any) -> int:
60+
"""Estimate retained Python memory without serializing or calling render()."""
61+
total = 0
62+
seen: set[int] = set()
63+
stack: list[Any] = [value]
64+
while stack:
65+
item = stack.pop()
66+
identity = id(item)
67+
if identity in seen:
68+
continue
69+
seen.add(identity)
70+
try:
71+
total += sys.getsizeof(item)
72+
except Exception: # noqa: BLE001 - estimation must never break a hook
73+
total += 1_024
74+
continue
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 are best-effort
84+
pass
85+
return total
86+
87+
6088
# Queue entry kinds.
6189
_RECORD = "record"
6290
_FLUSH = "flush"
@@ -80,11 +108,12 @@ class _FlushBarrier:
80108
a later, still-blocked worker skips the now-pointless flush.
81109
"""
82110

83-
__slots__ = ("_event", "canceled")
111+
__slots__ = ("_event", "canceled", "failed")
84112

85113
def __init__(self) -> None:
86114
self._event = threading.Event()
87115
self.canceled = False
116+
self.failed = False
88117

89118
def complete(self) -> None:
90119
self._event.set()
@@ -138,25 +167,20 @@ def __init__(
138167
self._next_sequence = 0
139168
self._stop_when_idle = False
140169
self._worker: threading.Thread | None = None
170+
self._disabled = False
141171

142172
# -- producer API (checkpoint / invocation-end threads) -------------------
143173

144174
def schedule(
145175
self,
146176
execution_arn: str,
147177
record: dict[str, Any],
148-
record_size: int | None,
178+
record_size: int,
149179
) -> None:
150180
with self._cond:
151-
self._stop_when_idle = False
152-
if record_size is None:
153-
_logger.warning(
154-
"workflow-insight: cannot measure pending record for %s on "
155-
"%s; dropping this record",
156-
execution_arn,
157-
type(self._exporter).__name__,
158-
)
181+
if self._disabled:
159182
return
183+
self._stop_when_idle = False
160184
size = max(0, record_size)
161185
if size > self._max_pending_bytes:
162186
_logger.warning(
@@ -204,6 +228,11 @@ def schedule(
204228
def enqueue_flush(self) -> _FlushBarrier:
205229
barrier = _FlushBarrier()
206230
with self._cond:
231+
if self._disabled:
232+
barrier.canceled = True
233+
barrier.failed = True
234+
barrier.complete()
235+
return barrier
207236
self._queue.append((_FLUSH, barrier))
208237
self._generation += 1
209238
self._ensure_worker_locked()
@@ -332,19 +361,43 @@ def _remove_record_token(self, token: _RecordToken) -> None:
332361
del self._queue[index]
333362
return
334363

364+
def _disable_locked(self, exc: Exception) -> None:
365+
self._disabled = True
366+
self._worker = None
367+
self._pending.clear()
368+
self._pending_bytes = 0
369+
for kind, payload in self._queue:
370+
if kind == _FLUSH:
371+
barrier: _FlushBarrier = payload
372+
barrier.canceled = True
373+
barrier.failed = True
374+
barrier.complete()
375+
self._queue.clear()
376+
_logger.warning(
377+
"workflow-insight: could not start worker for exporter %s; "
378+
"disabling this lane: %s",
379+
type(self._exporter).__name__,
380+
exc,
381+
)
382+
335383
def _ensure_worker_locked(self) -> None:
336384
# Never create a replacement while a prior worker is alive (a blocked
337385
# worker keeps ``_worker`` non-None). A worker that exits cleanly nulls
338386
# ``_worker`` under the lock before returning, so this check is a
339387
# race-free "start iff there is no live worker".
388+
if self._disabled:
389+
return
340390
if self._worker is None or not self._worker.is_alive():
341391
worker = threading.Thread(
342392
target=self._run_worker,
343393
name=f"workflow-insight-export-{id(self)}",
344394
daemon=True,
345395
)
346396
self._worker = worker
347-
worker.start()
397+
try:
398+
worker.start()
399+
except Exception as exc: # noqa: BLE001 - instrumentation must not break hooks
400+
self._disable_locked(exc)
348401

349402
# -- worker (single daemon thread) ---------------------------------------
350403

@@ -487,7 +540,7 @@ def __init__(
487540

488541
def schedule(self, execution_arn: str, record: dict[str, Any]) -> None:
489542
"""Fan a canonical record out to every lane. Returns immediately."""
490-
record_size = json_byte_size(record)
543+
record_size = _estimate_retained_size(record)
491544
for lane in self._lanes:
492545
lane.schedule(execution_arn, record, record_size)
493546

@@ -510,6 +563,8 @@ def end_invocation(self, timeout_seconds: float) -> bool:
510563
# accumulate behind a blocked worker.
511564
lane.cancel_flush(barrier)
512565
degraded = True
566+
elif barrier.failed:
567+
degraded = True
513568
for lane in self._lanes:
514569
lane.request_stop_when_idle()
515570
if degraded:

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

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

1212
from __future__ import annotations
1313

14-
import json
1514
import logging
1615
import threading
1716
import time
@@ -183,6 +182,13 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Any:
183182
raise RuntimeError("uncopyable payload")
184183

185184

185+
class _Unsized:
186+
"""A payload whose custom ``__sizeof__`` raises."""
187+
188+
def __sizeof__(self) -> int:
189+
raise RuntimeError("size unavailable")
190+
191+
186192
# -- lazy worker creation / one worker per exporter --------------------------
187193

188194

@@ -220,6 +226,25 @@ def test_one_worker_per_exporter():
220226
)
221227

222228

229+
def test_worker_start_failure_disables_lane_and_fails_barrier(monkeypatch):
230+
exporter = RecordingExporter()
231+
scheduler = _ExportScheduler([exporter])
232+
lane = scheduler._lanes[0]
233+
234+
def fail_start(self):
235+
raise RuntimeError("cannot start new thread")
236+
237+
monkeypatch.setattr(threading.Thread, "start", fail_start)
238+
scheduler.schedule(ARN_A, _rec(ARN_A, "a1"))
239+
240+
assert lane._disabled is True
241+
assert lane._pending_count() == 0
242+
assert lane._pending_bytes_count() == 0
243+
assert lane._queue_len() == 0
244+
assert scheduler.end_invocation(0.1) is False
245+
assert exporter.exported_values() == []
246+
247+
223248
def test_repeated_scheduling_does_not_grow_threads():
224249
base = _insight_thread_count()
225250
exporter = RecordingExporter()
@@ -629,7 +654,7 @@ def test_pending_record_cap_preserves_original_lane_memory_bound():
629654
assert exporter.exported_values() == ["a1", "c1", "d1", "a2"]
630655

631656

632-
def test_unmeasurable_record_does_not_evict_existing_backlog():
657+
def test_non_json_record_reaches_exporter_without_evicting_backlog():
633658
exporter = BlockingExporter()
634659
scheduler = _ExportScheduler([exporter])
635660
lane = scheduler._lanes[0]
@@ -638,19 +663,19 @@ def test_unmeasurable_record_does_not_evict_existing_backlog():
638663
scheduler.schedule(ARN_B, _rec(ARN_B, "b1"))
639664
scheduler.schedule(ARN_C, _rec(ARN_C, "c1"))
640665

641-
unmeasurable = _rec(ARN_D, "bad")
642-
unmeasurable["payload"] = {"not-json"}
643-
scheduler.schedule(ARN_D, unmeasurable)
666+
non_json = _rec(ARN_D, "custom")
667+
non_json["payload"] = {"not-json"}
668+
scheduler.schedule(ARN_D, non_json)
644669

645-
assert lane._pending_count() == 2
670+
assert lane._pending_count() == 3
646671
exporter.release()
647672
scheduler.end_invocation(5.0)
648-
assert exporter.exported_values() == ["inflight", "b1", "c1"]
673+
assert exporter.exported_values() == ["inflight", "b1", "c1", "custom"]
649674

650675

651676
def test_individually_over_budget_record_does_not_evict_existing_backlog():
652677
exporter = BlockingExporter()
653-
scheduler = _ExportScheduler([exporter], max_pending_bytes=2_000)
678+
scheduler = _ExportScheduler([exporter], max_pending_bytes=3_500)
654679
lane = scheduler._lanes[0]
655680
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
656681
assert _wait_until(exporter.started.is_set)
@@ -659,29 +684,28 @@ def test_individually_over_budget_record_does_not_evict_existing_backlog():
659684
scheduler.schedule(ARN_D, _rec(ARN_D, "d" * 3_000))
660685

661686
assert lane._pending_count() == 2
662-
assert lane._pending_bytes_count() <= 2_000
687+
assert lane._pending_bytes_count() <= 3_500
663688
exporter.release()
664689
scheduler.end_invocation(5.0)
665690
exported = exporter.exported_values()
666691
assert exported[0] == "inflight"
667692
assert exported[1:] == ["b" * 700, "c" * 700]
668693

669694

670-
def test_record_sizing_exception_does_not_escape_schedule(monkeypatch):
695+
def test_record_sizing_exception_does_not_escape_schedule():
671696
exporter = BlockingExporter()
672697
scheduler = _ExportScheduler([exporter])
673698
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
674699
assert _wait_until(exporter.started.is_set)
675700

676-
def fail_sizing(*args, **kwargs):
677-
raise RecursionError("record nesting is too deep")
678-
679-
monkeypatch.setattr(json, "dumps", fail_sizing)
680-
scheduler.schedule(ARN_B, _rec(ARN_B, "too-deep"))
701+
record = _rec(ARN_B, "custom-sized")
702+
record["payload"] = _Unsized()
703+
scheduler.schedule(ARN_B, record)
681704

682-
assert scheduler._lanes[0]._pending_count() == 0
705+
assert scheduler._lanes[0]._pending_count() == 1
683706
exporter.release()
684707
scheduler.end_invocation(5.0)
708+
assert exporter.exported_values() == ["inflight", "custom-sized"]
685709

686710

687711
def test_pending_record_cap_evicts_true_oldest_across_arns():
@@ -703,7 +727,7 @@ def test_pending_record_cap_evicts_true_oldest_across_arns():
703727

704728
def test_pending_byte_budget_evicts_oldest_large_record():
705729
exporter = BlockingExporter()
706-
scheduler = _ExportScheduler([exporter], max_pending_bytes=2_000)
730+
scheduler = _ExportScheduler([exporter], max_pending_bytes=3_000)
707731
lane = scheduler._lanes[0]
708732
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
709733
assert _wait_until(exporter.started.is_set)
@@ -712,7 +736,7 @@ def test_pending_byte_budget_evicts_oldest_large_record():
712736
scheduler.schedule(ARN_C, _rec(ARN_C, "c" * 1_500))
713737

714738
assert lane._pending_record_count() == 1
715-
assert lane._pending_bytes_count() <= 2_000
739+
assert lane._pending_bytes_count() <= 3_000
716740
exporter.release()
717741
scheduler.end_invocation(5.0)
718742
exported = exporter.exported_values()

0 commit comments

Comments
 (0)