Skip to content

Commit 4bd5e14

Browse files
author
Alex Wang
committed
fix(insight): bound retained traversal work
1 parent 9a9cb0e commit 4bd5e14

4 files changed

Lines changed: 167 additions & 28 deletions

File tree

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

Lines changed: 127 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
from __future__ import annotations
2222

2323
import copy
24-
import gc
24+
import functools
25+
import itertools
2526
import logging
2627
import sys
2728
import threading
@@ -48,44 +49,149 @@
4849
_DEFAULT_MAX_PENDING_BYTES = 16_000_000
4950

5051

51-
_RETAINED_GRAPH_BOUNDARIES = (
52+
_UNSUPPORTED_RETAINED_GRAPH = object()
53+
_ATOMIC_RETAINED_TYPES = (
54+
str,
55+
bytes,
56+
bytearray,
57+
int,
58+
float,
59+
complex,
60+
bool,
61+
type(None),
62+
range,
63+
slice,
5264
type,
5365
types.ModuleType,
54-
types.FunctionType,
55-
types.BuiltinFunctionType,
5666
types.CodeType,
67+
types.WrapperDescriptorType,
68+
types.MethodDescriptorType,
5769
)
5870

5971

72+
class _RetainedChildren:
73+
__slots__ = ("iterator",)
74+
75+
def __init__(self, items: Any) -> None:
76+
self.iterator = iter(items)
77+
78+
79+
def _custom_retained_children(value: Any) -> list[Any]:
80+
children: list[Any] = []
81+
try:
82+
children.append(object.__getattribute__(value, "__dict__"))
83+
except Exception: # noqa: BLE001 - custom objects may use slots only
84+
pass
85+
for cls in type(value).__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+
children.append(object.__getattribute__(value, slot))
96+
except Exception: # noqa: BLE001 - unset/custom slots are best-effort
97+
pass
98+
return children
99+
100+
101+
def _retained_children(value: Any) -> Any:
102+
custom = _custom_retained_children(value)
103+
if isinstance(value, dict):
104+
return itertools.chain(dict.__iter__(value), dict.values(value), custom)
105+
if isinstance(value, list):
106+
return itertools.chain(list.__iter__(value), custom)
107+
if isinstance(value, tuple):
108+
return itertools.chain(tuple.__iter__(value), custom)
109+
if isinstance(value, set):
110+
return itertools.chain(set.__iter__(value), custom)
111+
if isinstance(value, frozenset):
112+
return itertools.chain(frozenset.__iter__(value), custom)
113+
if isinstance(value, deque):
114+
return itertools.chain(deque.__iter__(value), custom)
115+
if isinstance(value, memoryview):
116+
return (value.obj,)
117+
if isinstance(value, functools.partial):
118+
return (value.func, value.args, value.keywords)
119+
if isinstance(value, types.FunctionType):
120+
closure = []
121+
for cell in value.__closure__ or ():
122+
try:
123+
closure.append(cell.cell_contents)
124+
except ValueError:
125+
pass
126+
return itertools.chain(closure, (value.__defaults__, value.__kwdefaults__))
127+
if isinstance(value, types.MethodType):
128+
return (value.__self__, value.__func__)
129+
if isinstance(value, types.BuiltinFunctionType):
130+
owner = value.__self__
131+
return () if owner is None or isinstance(owner, types.ModuleType) else (owner,)
132+
if isinstance(value, types.MethodWrapperType):
133+
return (value.__self__,)
134+
if isinstance(value, types.GeneratorType):
135+
frame = value.gi_frame
136+
return () if frame is None else (frame.f_locals, value.gi_yieldfrom)
137+
if isinstance(value, _ATOMIC_RETAINED_TYPES):
138+
return ()
139+
if custom:
140+
return custom
141+
return _UNSUPPORTED_RETAINED_GRAPH
142+
143+
144+
def _retained_shallow_size(value: Any) -> int:
145+
try:
146+
size = sys.getsizeof(value)
147+
except Exception: # noqa: BLE001 - estimation must never break a hook
148+
size = 1_024
149+
try:
150+
if isinstance(value, dict):
151+
size = max(size, dict.__sizeof__(value))
152+
elif isinstance(value, list):
153+
size = max(size, list.__sizeof__(value))
154+
elif isinstance(value, tuple):
155+
size = max(size, tuple.__sizeof__(value))
156+
elif isinstance(value, set):
157+
size = max(size, set.__sizeof__(value))
158+
elif isinstance(value, frozenset):
159+
size = max(size, frozenset.__sizeof__(value))
160+
elif isinstance(value, deque):
161+
size = max(size, deque.__sizeof__(value))
162+
except Exception: # noqa: BLE001 - base sizing remains best-effort
163+
pass
164+
return size
165+
166+
60167
def _estimate_retained_size(value: Any, max_size: int | None = None) -> int:
61-
"""Estimate a bounded retained graph without serializing or calling render()."""
168+
"""Estimate retained memory with bounded, non-overridable traversal."""
62169
total = 0
63170
seen: set[int] = set()
64171
stack: list[Any] = [value]
65172
while stack:
66173
item = stack.pop()
174+
if isinstance(item, _RetainedChildren):
175+
try:
176+
child = next(item.iterator)
177+
except StopIteration:
178+
continue
179+
except Exception: # noqa: BLE001 - fail closed on malformed iterators
180+
return max_size + 1 if max_size is not None else total + 1_024
181+
stack.append(item)
182+
stack.append(child)
183+
continue
67184
identity = id(item)
68185
if identity in seen:
69186
continue
70187
seen.add(identity)
71-
try:
72-
total += sys.getsizeof(item)
73-
except Exception: # noqa: BLE001 - estimation must never break a hook
74-
total += 1_024
188+
total += _retained_shallow_size(item)
75189
if max_size is not None and total > max_size:
76190
return max_size + 1
77-
try:
78-
referents = gc.get_referents(item)
79-
except Exception: # noqa: BLE001 - estimation must never break a hook
80-
continue
81-
for referent in referents:
82-
# Type/module/function/code objects lead into process-global graphs,
83-
# not memory retained specifically by this record. Bound methods,
84-
# partial args, generator iterators, slots, buffers, and container
85-
# subclasses remain traversable through their other referents.
86-
if isinstance(referent, _RETAINED_GRAPH_BOUNDARIES):
87-
continue
88-
stack.append(referent)
191+
children = _retained_children(item)
192+
if children is _UNSUPPORTED_RETAINED_GRAPH:
193+
return max_size + 1 if max_size is not None else total + 1_024
194+
stack.append(_RetainedChildren(children))
89195
return total
90196

91197

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,6 @@
7979
def _claim_exporter_lanes(lanes: list[Any]) -> None:
8080
"""Give each exporter object to at most one live scheduler lane."""
8181

82-
def release(lane_ref: weakref.ReferenceType[Any]) -> None:
83-
with _exporter_owner_lock:
84-
_exporter_owners[:] = [
85-
existing for existing in _exporter_owners if existing is not lane_ref
86-
]
87-
8882
with _exporter_owner_lock:
8983
_exporter_owners[:] = [
9084
lane_ref for lane_ref in _exporter_owners if lane_ref() is not None
@@ -98,7 +92,9 @@ def release(lane_ref: weakref.ReferenceType[Any]) -> None:
9892
"the same exporter instance cannot be shared across "
9993
"Workflow Insight plugin instances"
10094
)
101-
_exporter_owners.extend(weakref.ref(lane, release) for lane in lanes)
95+
# Callback-free weakrefs avoid lock re-entry during synchronous finalization.
96+
# Dead entries are pruned at the start of every subsequent claim.
97+
_exporter_owners.extend(weakref.ref(lane) for lane in lanes)
10298

10399

104100
def _parse_execution_arn(execution_arn: str) -> dict[str, str]:

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import pytest
2020

21+
import aws_durable_execution_sdk_python_insight.plugin as insight_plugin_module
2122
from aws_durable_execution_sdk_python_insight import (
2223
EmitMode,
2324
OperationDetail,
@@ -278,6 +279,10 @@ def test_exporter_plugin_cycle_is_not_rooted_by_ownership_registry():
278279
exporter_ref = weakref.ref(exporter)
279280
plugin_ref = weakref.ref(plugin)
280281
lane_ref = weakref.ref(lane)
282+
assert all(
283+
owner_ref.__callback__ is None
284+
for owner_ref in insight_plugin_module._exporter_owners
285+
)
281286

282287
del lane
283288
del plugin

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import functools
1515
import threading
1616
import time
17+
import types
1718
from typing import Any
1819

1920
from aws_durable_execution_sdk_python_insight._export_scheduler import (
@@ -217,6 +218,9 @@ def __init__(self) -> None:
217218
super().__init__([None] * 10_000)
218219
self.iterated = False
219220

221+
def __sizeof__(self) -> int:
222+
return 1
223+
220224
def __iter__(self):
221225
self.iterated = True
222226
return super().__iter__()
@@ -743,6 +747,34 @@ def __iter__(self):
743747
assert hidden.iterated is False
744748

745749

750+
def test_retained_size_counts_closures_and_bound_builtin_owners():
751+
closure_buffer = bytearray(4_000)
752+
753+
def closure() -> bytearray:
754+
return closure_buffer
755+
756+
bound_owner = [bytearray(4_000)]
757+
wrapper_owner = [bytearray(4_000)]
758+
method_wrapper = wrapper_owner.__str__
759+
assert isinstance(method_wrapper, types.MethodWrapperType)
760+
payloads = [closure, bound_owner.append, method_wrapper]
761+
762+
for payload in payloads:
763+
exporter = BlockingExporter()
764+
scheduler = _ExportScheduler([exporter], max_pending_bytes=2_500)
765+
lane = scheduler._lanes[0]
766+
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
767+
assert _wait_until(exporter.started.is_set)
768+
record = _rec(ARN_B, "retained-callable")
769+
record["payload"] = payload
770+
scheduler.schedule(ARN_B, record)
771+
772+
assert lane._pending_count() == 0
773+
assert lane._pending_bytes_count() == 0
774+
exporter.release()
775+
scheduler.end_invocation(5.0)
776+
777+
746778
def test_timed_out_barrier_flushes_eventually_and_worker_exits():
747779
exporter = BlockingExporter()
748780
scheduler = _ExportScheduler([exporter])

0 commit comments

Comments
 (0)