Skip to content

Commit f91e2eb

Browse files
author
Alex Wang
committed
fix(insight): bound FIFO traversal work
1 parent a291811 commit f91e2eb

2 files changed

Lines changed: 159 additions & 21 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
@@ -20,7 +20,8 @@
2020
from __future__ import annotations
2121

2222
import copy
23-
import gc
23+
import functools
24+
import itertools
2425
import logging
2526
import sys
2627
import threading
@@ -58,44 +59,149 @@
5859
_DEFAULT_MAX_PENDING_BYTES = 16_000_000
5960

6061

61-
_RETAINED_GRAPH_BOUNDARIES = (
62+
_UNSUPPORTED_RETAINED_GRAPH = object()
63+
_ATOMIC_RETAINED_TYPES = (
64+
str,
65+
bytes,
66+
bytearray,
67+
int,
68+
float,
69+
complex,
70+
bool,
71+
type(None),
72+
range,
73+
slice,
6274
type,
6375
types.ModuleType,
64-
types.FunctionType,
65-
types.BuiltinFunctionType,
6676
types.CodeType,
77+
types.WrapperDescriptorType,
78+
types.MethodDescriptorType,
6779
)
6880

6981

82+
class _RetainedChildren:
83+
__slots__ = ("iterator",)
84+
85+
def __init__(self, items: Any) -> None:
86+
self.iterator = iter(items)
87+
88+
89+
def _custom_retained_children(value: Any) -> list[Any]:
90+
children: list[Any] = []
91+
try:
92+
children.append(object.__getattribute__(value, "__dict__"))
93+
except Exception: # noqa: BLE001 - custom objects may use slots only
94+
pass
95+
for cls in type(value).__mro__:
96+
slots = vars(cls).get("__slots__", ())
97+
if isinstance(slots, str):
98+
slots = (slots,)
99+
for slot in slots:
100+
if slot in {"__dict__", "__weakref__"}:
101+
continue
102+
if slot.startswith("__") and not slot.endswith("__"):
103+
slot = f"_{cls.__name__.lstrip('_')}{slot}"
104+
try:
105+
children.append(object.__getattribute__(value, slot))
106+
except Exception: # noqa: BLE001 - unset/custom slots are best-effort
107+
pass
108+
return children
109+
110+
111+
def _retained_children(value: Any) -> Any:
112+
custom = _custom_retained_children(value)
113+
if isinstance(value, dict):
114+
return itertools.chain(dict.__iter__(value), dict.values(value), custom)
115+
if isinstance(value, list):
116+
return itertools.chain(list.__iter__(value), custom)
117+
if isinstance(value, tuple):
118+
return itertools.chain(tuple.__iter__(value), custom)
119+
if isinstance(value, set):
120+
return itertools.chain(set.__iter__(value), custom)
121+
if isinstance(value, frozenset):
122+
return itertools.chain(frozenset.__iter__(value), custom)
123+
if isinstance(value, deque):
124+
return itertools.chain(deque.__iter__(value), custom)
125+
if isinstance(value, memoryview):
126+
return (value.obj,)
127+
if isinstance(value, functools.partial):
128+
return (value.func, value.args, value.keywords)
129+
if isinstance(value, types.FunctionType):
130+
closure = []
131+
for cell in value.__closure__ or ():
132+
try:
133+
closure.append(cell.cell_contents)
134+
except ValueError:
135+
pass
136+
return itertools.chain(closure, (value.__defaults__, value.__kwdefaults__))
137+
if isinstance(value, types.MethodType):
138+
return (value.__self__, value.__func__)
139+
if isinstance(value, types.BuiltinFunctionType):
140+
owner = value.__self__
141+
return () if owner is None or isinstance(owner, types.ModuleType) else (owner,)
142+
if isinstance(value, types.MethodWrapperType):
143+
return (value.__self__,)
144+
if isinstance(value, types.GeneratorType):
145+
frame = value.gi_frame
146+
return () if frame is None else (frame.f_locals, value.gi_yieldfrom)
147+
if isinstance(value, _ATOMIC_RETAINED_TYPES):
148+
return ()
149+
if custom:
150+
return custom
151+
return _UNSUPPORTED_RETAINED_GRAPH
152+
153+
154+
def _retained_shallow_size(value: Any) -> int:
155+
try:
156+
size = sys.getsizeof(value)
157+
except Exception: # noqa: BLE001 - estimation must never break a hook
158+
size = 1_024
159+
try:
160+
if isinstance(value, dict):
161+
size = max(size, dict.__sizeof__(value))
162+
elif isinstance(value, list):
163+
size = max(size, list.__sizeof__(value))
164+
elif isinstance(value, tuple):
165+
size = max(size, tuple.__sizeof__(value))
166+
elif isinstance(value, set):
167+
size = max(size, set.__sizeof__(value))
168+
elif isinstance(value, frozenset):
169+
size = max(size, frozenset.__sizeof__(value))
170+
elif isinstance(value, deque):
171+
size = max(size, deque.__sizeof__(value))
172+
except Exception: # noqa: BLE001 - base sizing remains best-effort
173+
pass
174+
return size
175+
176+
70177
def _estimate_retained_size(value: Any, max_size: int | None = None) -> int:
71-
"""Estimate a bounded retained graph without serializing or calling render()."""
178+
"""Estimate retained memory with bounded, non-overridable traversal."""
72179
total = 0
73180
seen: set[int] = set()
74181
stack: list[Any] = [value]
75182
while stack:
76183
item = stack.pop()
184+
if isinstance(item, _RetainedChildren):
185+
try:
186+
child = next(item.iterator)
187+
except StopIteration:
188+
continue
189+
except Exception: # noqa: BLE001 - fail closed on malformed iterators
190+
return max_size + 1 if max_size is not None else total + 1_024
191+
stack.append(item)
192+
stack.append(child)
193+
continue
77194
identity = id(item)
78195
if identity in seen:
79196
continue
80197
seen.add(identity)
81-
try:
82-
total += sys.getsizeof(item)
83-
except Exception: # noqa: BLE001 - estimation must never break a hook
84-
total += 1_024
198+
total += _retained_shallow_size(item)
85199
if max_size is not None and total > max_size:
86200
return max_size + 1
87-
try:
88-
referents = gc.get_referents(item)
89-
except Exception: # noqa: BLE001 - estimation must never break a hook
90-
continue
91-
for referent in referents:
92-
# Type/module/function/code objects lead into process-global graphs,
93-
# not memory retained specifically by this record. Bound methods,
94-
# partial args, generator iterators, slots, buffers, and container
95-
# subclasses remain traversable through their other referents.
96-
if isinstance(referent, _RETAINED_GRAPH_BOUNDARIES):
97-
continue
98-
stack.append(referent)
201+
children = _retained_children(item)
202+
if children is _UNSUPPORTED_RETAINED_GRAPH:
203+
return max_size + 1 if max_size is not None else total + 1_024
204+
stack.append(_RetainedChildren(children))
99205
return total
100206

101207

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 (
@@ -235,6 +236,9 @@ def __init__(self) -> None:
235236
super().__init__([None] * 10_000)
236237
self.iterated = False
237238

239+
def __sizeof__(self) -> int:
240+
return 1
241+
238242
def __iter__(self):
239243
self.iterated = True
240244
return super().__iter__()
@@ -921,6 +925,34 @@ def __iter__(self):
921925
assert hidden.iterated is False
922926

923927

928+
def test_retained_size_counts_closures_and_bound_builtin_owners():
929+
closure_buffer = bytearray(4_000)
930+
931+
def closure() -> bytearray:
932+
return closure_buffer
933+
934+
bound_owner = [bytearray(4_000)]
935+
wrapper_owner = [bytearray(4_000)]
936+
method_wrapper = wrapper_owner.__str__
937+
assert isinstance(method_wrapper, types.MethodWrapperType)
938+
payloads = [closure, bound_owner.append, method_wrapper]
939+
940+
for payload in payloads:
941+
exporter = BlockingExporter()
942+
scheduler = _ExportScheduler([exporter], max_pending_bytes=2_500)
943+
lane = scheduler._lanes[0]
944+
scheduler.schedule(ARN_A, _rec(ARN_A, "inflight"))
945+
assert _wait_until(exporter.started.is_set)
946+
record = _rec(ARN_B, "retained-callable")
947+
record["payload"] = payload
948+
scheduler.schedule(ARN_B, record)
949+
950+
assert lane._pending_count() == 0
951+
assert lane._pending_bytes_count() == 0
952+
exporter.release()
953+
scheduler.end_invocation(5.0)
954+
955+
924956
def test_timed_out_barrier_flushes_eventually_and_worker_exits():
925957
exporter = BlockingExporter()
926958
scheduler = _ExportScheduler([exporter])

0 commit comments

Comments
 (0)