Skip to content

Commit f5174ab

Browse files
committed
fix(insight): release displaced records outside every lock
`_emit` hands a record to the scheduler while holding this instance's lock, and that hand-off displaces the record already pending for the execution. Releasing the displaced one can run a customer `__del__`, because the record is a snapshot of customer data, and a finalizer that reaches another execution's plugin blocks on that instance's lock. Two threads doing that to each other at once hang both invocations, and the invocation end is awaited before the response. The reentrant lock does not help: it covers re-entry on the same instance, which is why customer code inside a build can call this execution's hooks safely, and says nothing about a second instance. `schedule()` now returns what it displaced and the hook frame releases it once the outermost hook returns, with no lock held. Releases run before the deferred drains, so a finalizer that schedules a record is covered by the drain that follows it.
1 parent 869f6b9 commit f5174ab

3 files changed

Lines changed: 96 additions & 10 deletions

File tree

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,28 @@ def __init__(self, exporters: list[InsightExporter]) -> None:
144144
# a worker that keeps making progress never approaches the bound.
145145
self._worker_faults = 0
146146

147-
def schedule(self, execution: _ExportState, record: dict[str, Any]) -> None:
148-
"""Replace this execution's pending snapshot; never runs exporters inline."""
147+
def schedule(self, execution: _ExportState, record: dict[str, Any]) -> list[Any]:
148+
"""Replace this execution's pending snapshot; never runs exporters inline.
149+
150+
Returns the records this call displaced, for the caller to release once it
151+
holds no lock. They are handed back rather than dropped here because
152+
releasing one can run a customer ``__del__``, and this method is called
153+
from inside the calling plugin's own lock. A finalizer that re-entered a
154+
*different* execution's plugin would then block on that instance's lock
155+
while holding this one, which deadlocks both invocations if the mirror
156+
image happens on another thread at the same time. The plugin's hook frame
157+
drops them when the outermost hook returns; see
158+
``WorkflowInsightPlugin._hook_frame``.
159+
160+
A caller with no frame to defer to may drop the returned list
161+
immediately: doing so is only unsafe while a plugin lock is held.
162+
"""
149163
displaced: dict[str, Any] | None = None
150164
failed_pending: _Dropped | None = None
151165
start_error: Exception | None = None
152166
with self._condition:
153167
if self._disabled:
154-
return
168+
return []
155169
self._seq += 1
156170
execution.scheduled_seq = self._seq
157171
displaced = execution.pending_record
@@ -161,14 +175,13 @@ def schedule(self, execution: _ExportState, record: dict[str, Any]) -> None:
161175
self._pending[execution] = None
162176
failed_pending, start_error = self._ensure_worker_locked()
163177
self._condition.notify_all()
164-
# Releasing either record may run custom finalizers, so do it unlocked.
165-
del displaced, failed_pending
166178
if start_error is not None:
167179
_logger.warning(
168180
"workflow-insight: could not start export worker; disabling "
169181
"asynchronous export: %s",
170182
start_error,
171183
)
184+
return [item for item in (displaced, failed_pending) if item is not None]
172185

173186
def drain(self, execution: _ExportState) -> None:
174187
"""Wait until this execution's latest record is exported and exporters flush.

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ class _HookFrames(threading.local):
191191
def __init__(self) -> None:
192192
self.depth = 0
193193
self.pending: list[WorkflowInsightPlugin] = []
194+
# Records displaced from the scheduler's pending slots, held until every
195+
# plugin lock on this thread is released. Releasing one can run a customer
196+
# finalizer, and a finalizer that reaches another execution's plugin must
197+
# not do so while this thread holds a plugin lock.
198+
self.releases: list[Any] = []
194199

195200

196201
_hook_frames = _HookFrames()
@@ -341,10 +346,16 @@ def _hook_frame(self) -> Iterator[None]:
341346
yield
342347
finally:
343348
state.depth -= 1
344-
if state.depth == 0 and state.pending:
345-
owed, state.pending = state.pending, []
346-
for plugin in owed:
347-
plugin._drain()
349+
if state.depth == 0:
350+
# Released before the drains, and with no lock held: a finalizer
351+
# that schedules a record is then covered by the drain that
352+
# follows it.
353+
if state.releases:
354+
state.releases.clear()
355+
if state.pending:
356+
owed, state.pending = state.pending, []
357+
for plugin in owed:
358+
plugin._drain()
348359

349360
def _request_drain(self) -> None:
350361
"""Ask for a drain once the outermost hook frame on this thread unwinds."""
@@ -645,7 +656,13 @@ def _emit(
645656
# every later non-terminal record is rejected above.
646657
if not closing and (self._closed or revision != self._build_revision):
647658
return
648-
self._shared._scheduler.schedule(self, record)
659+
# The records this hand-off displaces are released by the hook frame, not
660+
# here: this runs inside `_lock`, and a displaced record can carry a
661+
# customer object whose `__del__` re-enters a hook. Re-entering *this*
662+
# execution's hook is safe because `_lock` is reentrant; re-entering
663+
# another execution's is not, and two threads doing it to each other at
664+
# once would hang both invocations. See `_hook_frame`.
665+
_hook_frames.releases.extend(self._shared._scheduler.schedule(self, record))
649666

650667

651668
class _WorkflowInsightFactory:

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,62 @@ def failing_on_the_second_call(value: Any) -> Any:
12401240
assert _wait_until(lambda: not factory._scheduler._worker_alive())
12411241

12421242

1243+
def test_a_displaced_records_finalizer_runs_with_no_plugin_lock_held():
1244+
# `_emit` hands the record to the scheduler while holding this instance's
1245+
# `_lock`, and that hand-off displaces the record already pending for this
1246+
# execution. Releasing the displaced one can run a customer `__del__` -- it is
1247+
# a snapshot of customer data -- and a finalizer that reaches ANOTHER
1248+
# execution's plugin blocks on that instance's lock. Two threads doing that to
1249+
# each other at once hang both invocations, which the reentrant lock does not
1250+
# help with: it only covers re-entry on the same instance. The hook frame
1251+
# therefore releases displaced records after every lock is dropped.
1252+
exporter = ConcurrentCaptureExporter()
1253+
observed: list[bool] = []
1254+
holder: dict[str, Any] = {}
1255+
1256+
class _FinalizerProbe:
1257+
def __del__(self) -> None:
1258+
plugin = holder.get("plugin")
1259+
if plugin is None:
1260+
return
1261+
# `_is_owned()` answers "does the calling thread hold this lock",
1262+
# which is the question here. Acquiring it would not: an RLock lets
1263+
# its owner acquire it again.
1264+
observed.append(not plugin._lock._is_owned())
1265+
1266+
# A transform returning a fresh object per emit is what makes the record the
1267+
# only reference to it, so releasing the displaced record is what collects it.
1268+
factory = workflow_insight(
1269+
WorkflowInsightConfig(
1270+
exporters=[exporter],
1271+
emit_mode="on-change",
1272+
content=ContentConfig(input=lambda value: _FinalizerProbe()),
1273+
)
1274+
)
1275+
plugin = factory.create_plugin(_start(operations={}))
1276+
holder["plugin"] = plugin
1277+
1278+
# The first emit queues a record holding a probe; the next displaces it while
1279+
# the worker is still parked, so the release happens on this thread.
1280+
plugin.on_invocation_start(_start(operations={}))
1281+
plugin.on_operation_change(
1282+
OperationChangeInfo(
1283+
execution_arn=ARN,
1284+
updated_operations=_ops(_step("s")),
1285+
operations=_ops(_step("s")),
1286+
)
1287+
)
1288+
plugin.on_invocation_end(_end(operations=_ops(_step("s"))))
1289+
1290+
assert observed, "the displaced record's finalizer must have run"
1291+
assert all(observed), (
1292+
"a displaced record was released while this thread still held the "
1293+
"plugin's lock, which is what deadlocks two invocations whose finalizers "
1294+
"reach each other"
1295+
)
1296+
assert _wait_until(lambda: not factory._scheduler._worker_alive())
1297+
1298+
12431299
def _free_for_another_thread(lock: Any) -> bool:
12441300
"""Report whether a lock is unheld, as seen from a thread that never took it.
12451301

0 commit comments

Comments
 (0)