Skip to content

Commit 869f6b9

Browse files
committed
fix(insight): flush a record already taken for export
The condition on the refused drain's flush request was pending-only, and the worker takes a record out of the pending map before it calls the exporter. A hook re-entered from inside export() that reached an invocation end emitting no record therefore found nothing pending, asked for no flush, and left the snapshot it had just been handed in a buffering exporter until the environment froze -- the loss the request exists to prevent, arriving by the other door. The condition now separates the two re-entry paths, because they need opposite answers: a flush already in flight with nothing newly pending is the one case that asks for nothing, which is what stops an exporter whose flush() re-enters from flushing for as long as the environment lives. The plugin contract also said a plugin may hold "per-execution" state in its attributes. Per invocation is narrower, and the difference is the replay model: an execution spans as many invocations as it waits, retries or resumes, and the instance is dropped when each returns. The README and the factory protocol now say per-invocation and name the two homes for anything that has to outlive it -- the operation-map snapshot the invocation hooks carry, or the factory.
1 parent 9a7206d commit 869f6b9

4 files changed

Lines changed: 83 additions & 11 deletions

File tree

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -301,22 +301,27 @@ def _is_export_worker(self) -> bool:
301301
return self._worker is threading.current_thread()
302302

303303
def _request_flush_for_pending_records(self) -> None:
304-
"""Ask the worker for a flush, but only if a record is waiting for one.
304+
"""Ask the worker for a flush, unless a flush already in flight covers it.
305305
306306
Returns without waiting, so it is safe to call from the worker itself. The
307307
barrier is raised to the current schedule counter, which is what makes the
308308
flush cover a record queued moments ago rather than running before it.
309309
310-
A pending record is the condition, not a formality. This is called from a
311-
drain refused on the worker thread, and one way to reach that is an
312-
exporter whose ``flush()`` re-enters a plugin hook: the call then arrives
313-
from inside a flush, and requesting the next one unconditionally would
314-
produce a flush that re-enters, requests, and flushes again for as long as
315-
the environment lives -- after the invocation has returned. Nothing is
316-
pending in that case, so nothing is requested.
310+
The condition separates the two ways a refused drain is reached, because
311+
they need opposite answers. Re-entered from an exporter's ``export()``,
312+
the record has already left ``_pending`` -- the worker takes it before it
313+
calls the exporter -- so a pending-only test would skip the request and
314+
leave that snapshot buffered until the environment froze. Re-entered from
315+
an exporter's ``flush()``, a flush is in flight and covers what was
316+
exported before it, so requesting another would produce a flush that
317+
re-enters, requests, and flushes again for as long as the environment
318+
lived -- after the invocation returned. A flush in flight with nothing
319+
newly pending is therefore the one case that asks for nothing.
317320
"""
318321
with self._condition:
319-
if self._disabled or not self._pending:
322+
if self._disabled:
323+
return
324+
if not self._pending and self._flush_in_flight is not None:
320325
return
321326
self._flush_requested = True
322327
self._flush_barrier = max(self._flush_barrier, self._seq)

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,3 +968,50 @@ def test_a_drain_refused_from_inside_a_flush_does_not_re_arm_it() -> None:
968968
# assertion: an unconditional request never settles.
969969
assert not _wait_until(lambda: exporter.flushes > flushes_after_drain, timeout=1.0)
970970
assert _wait_until(lambda: not scheduler._worker_alive())
971+
972+
973+
class ReentrantDrainDuringExportExporter(CaptureExporter):
974+
"""Exporter whose export() drains without queueing anything new.
975+
976+
The worker takes a record out of the pending map before it calls the
977+
exporter, so a hook re-entered from inside ``export()`` and reaching an
978+
invocation end that emits no record finds nothing pending -- while the record
979+
it was just handed is still only in the exporter's buffer.
980+
"""
981+
982+
def __init__(self) -> None:
983+
super().__init__()
984+
self.scheduler: _ArnScheduler | None = None
985+
self.returned = threading.Event()
986+
self._reentered = False
987+
988+
def export(self, record: dict[str, Any]) -> None:
989+
super().export(record)
990+
assert self.scheduler is not None
991+
if self._reentered:
992+
return
993+
self._reentered = True
994+
self.scheduler.drain(ARN_B)
995+
self.returned.set()
996+
997+
998+
def test_a_drain_refused_from_inside_an_export_still_flushes() -> None:
999+
"""A refused drain from inside an export asks for a flush.
1000+
1001+
The record it must cover has already left the pending map, so a
1002+
pending-only condition would leave that snapshot in a buffering exporter
1003+
when the environment froze -- which is the loss the refusal path exists to
1004+
prevent, arriving by the other door.
1005+
"""
1006+
exporter = ReentrantDrainDuringExportExporter()
1007+
scheduler = _ArnScheduler([exporter])
1008+
exporter.scheduler = scheduler
1009+
1010+
scheduler.schedule(ARN_A, _record("r1"))
1011+
1012+
assert exporter.returned.wait(timeout=10), "the refused drain must return"
1013+
assert _wait_until(lambda: ("flush", None) in exporter.calls), (
1014+
"the exported record must be flushed even though nothing was pending"
1015+
)
1016+
assert ("export", "r1") in exporter.calls
1017+
assert _wait_until(lambda: not scheduler._worker_alive())

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,18 @@ A plugin is registered as a *factory*, not as an instance. A factory is an objec
4747
with a `create_plugin(info)` method taking the invocation's `InvocationStartInfo`
4848
and returning a `DurableInstrumentationPlugin`; the SDK calls that method once per
4949
invocation, so the instance it returns serves that one invocation only and can
50-
hold per-execution state in ordinary attributes.
50+
hold **per-invocation** state in ordinary attributes.
51+
52+
Per-invocation is narrower than per-execution, and the difference matters. A
53+
durable execution spans as many invocations as it waits, retries or resumes, and
54+
the instance is dropped when each of those returns — so anything a plugin keeps in
55+
its attributes is gone by the next invocation of the same execution. State that
56+
has to survive that has two honest homes: rebuild it from the operation map the
57+
invocation hooks carry (`InvocationStartInfo.operations` is a full snapshot,
58+
which is how the bundled Insight plugin reports operations that completed in an
59+
earlier invocation), or put it on the factory, which outlives every invocation —
60+
keyed by execution ARN, and pruned by the owner, because the SDK will not tell the
61+
factory when an execution ends for good.
5162

5263
A factory is an object with a method rather than a plain callable so the
5364
registration type can grow a second, optional member later -- a process-level

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,9 +505,18 @@ def create_plugin(
505505
:class:`InvocationStartInfo` -- the same object the returned instance's
506506
``on_invocation_start`` then receives -- and before any hook fires. The
507507
instance serves only that invocation and is dropped when it returns, so a
508-
plugin can hold per-execution state in ordinary instance attributes
508+
plugin can hold that invocation's state in ordinary instance attributes
509509
without keying it by execution ARN.
510510
511+
Per invocation is narrower than per execution. A durable execution spans
512+
as many invocations as it waits, retries or resumes, so state a plugin
513+
leaves in its attributes is gone by the next invocation of the same
514+
execution. Anything that has to survive that is rebuilt from the operation
515+
map the invocation hooks carry -- ``InvocationStartInfo.operations`` is a
516+
full snapshot, including operations that completed in an earlier
517+
invocation -- or kept on the factory, which outlives every invocation and
518+
is therefore the caller's to key and to prune.
519+
511520
``info`` is positional-only, so an implementation may name the parameter
512521
whatever reads best; a named protocol parameter would pin that name for
513522
every implementation.

0 commit comments

Comments
 (0)