11# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
22#
33# SPDX-License-Identifier: Apache-2.0
4- """Latest-pending asynchronous export scheduling for Workflow Insight."""
4+ """Latest-pending asynchronous export scheduling for Workflow Insight.
5+
6+ One pending slot is kept **per execution** (keyed by ``executionArn``). Each
7+ record is a complete snapshot of its execution, so a newer snapshot for the same
8+ execution supersedes an older one that has not been exported yet, while records
9+ for different executions never displace each other. The plugin already tracks
10+ state per execution, and the local test runner drives independent executions
11+ concurrently through one shared plugin instance, so a single plugin-wide slot
12+ would silently drop one execution's terminal record whenever another execution
13+ scheduled a snapshot first.
14+ """
515
616from __future__ import annotations
717
1525
1626_logger = logging .getLogger ("aws_durable_execution_sdk_python_insight" )
1727
28+ _TERMINAL_STATUSES = frozenset ({"SUCCEEDED" , "FAILED" })
29+
30+
31+ def _is_terminal (record : dict [str , Any ]) -> bool :
32+ return record .get ("status" ) in _TERMINAL_STATUSES
33+
1834
1935class _ExportScheduler :
20- """Run all exporters on one lazy worker with one latest pending record."""
36+ """Run all exporters on one lazy worker with one pending record per execution ."""
2137
2238 def __init__ (self , exporters : list [InsightExporter ]) -> None :
2339 self ._exporters = exporters
2440 self ._condition = threading .Condition (threading .Lock ())
25- self ._pending : dict [str , Any ] | None = None
41+ # executionArn -> latest pending snapshot for that execution. Insertion
42+ # ordered, so the worker exports executions in first-arrival order;
43+ # replacing an entry keeps its position.
44+ self ._pending : dict [str , dict [str , Any ]] = {}
2645 self ._flush_requested = False
2746 self ._flush_event : threading .Event | None = None
2847 self ._worker : threading .Thread | None = None
29- self ._disabled = False
48+ self ._start_failure_logged = False
3049
3150 def schedule (self , record : dict [str , Any ]) -> None :
32- """Replace the pending snapshot and return without running exporters."""
51+ """Replace this execution's pending snapshot and return without exporting."""
52+ key = str (record .get ("executionArn" , "" ))
3353 displaced : dict [str , Any ] | None = None
34- failed_pending : dict [str , Any ] | None = None
3554 start_error : Exception | None = None
3655 with self ._condition :
37- if self ._disabled :
56+ displaced = self ._pending .get (key )
57+ # A terminal snapshot is final. A RUNNING snapshot for the same
58+ # execution that arrives after it (an operation-change hook from a
59+ # checkpoint completing during the end-of-invocation drain) must not
60+ # replace it, or the execution would be reported as still running.
61+ if (
62+ displaced is not None
63+ and _is_terminal (displaced )
64+ and not _is_terminal (record )
65+ ):
3866 return
39- displaced = self ._pending
40- self ._pending = record
41- failed_pending , start_error = self ._ensure_worker_locked ()
67+ self ._pending [key ] = record
68+ start_error = self ._ensure_worker_locked ()
4269 self ._condition .notify ()
43- # Releasing either record may run custom finalizers, so do it unlocked.
44- del displaced , failed_pending
45- if start_error is not None :
46- _logger .warning (
47- "workflow-insight: could not start export worker; disabling "
48- "asynchronous export: %s" ,
49- start_error ,
50- )
70+ # Releasing the displaced record may run custom finalizers, so do it unlocked.
71+ del displaced
72+ self ._log_start_failure (start_error )
5173
5274 def drain (self ) -> None :
53- """Wait until the latest pending record is exported and exporters flush."""
54- failed_pending : dict [str , Any ] | None = None
75+ """Wait until every pending record is exported and exporters flush.
76+
77+ Records scheduled by any execution are exported before the flush, so a
78+ drain issued at one execution's invocation end also delivers snapshots
79+ that a concurrently running execution scheduled earlier.
80+ """
5581 start_error : Exception | None = None
5682 with self ._condition :
57- if self ._disabled :
58- return
5983 if not self ._flush_requested :
6084 self ._flush_requested = True
6185 self ._flush_event = threading .Event ()
6286 flush_event = self ._flush_event
6387 assert flush_event is not None
64- failed_pending , start_error = self ._ensure_worker_locked ()
65- started = not self ._disabled
88+ start_error = self ._ensure_worker_locked ()
89+ worker_running = self ._worker is not None
6690 self ._condition .notify ()
67- del failed_pending
68- if start_error is not None :
69- _logger .warning (
70- "workflow-insight: could not start export worker; disabling "
71- "asynchronous export: %s" ,
72- start_error ,
73- )
74- if started :
91+ self ._log_start_failure (start_error )
92+ if worker_running :
7593 flush_event .wait ()
94+ return
95+ # No worker could be started. Export and flush on the calling thread so
96+ # nothing scheduled is dropped; this is the invocation-end path, which
97+ # already waits for delivery.
98+ self ._pump (flush_event )
7699
77- def _ensure_worker_locked (
78- self ,
79- ) -> tuple [dict [str , Any ] | None , Exception | None ]:
100+ def _ensure_worker_locked (self ) -> Exception | None :
80101 if self ._worker is not None and self ._worker .is_alive ():
81- return None , None
102+ return None
82103 worker = threading .Thread (
83104 target = self ._run ,
84105 name = f"workflow-insight-export-{ id (self )} " ,
@@ -88,32 +109,45 @@ def _ensure_worker_locked(
88109 try :
89110 worker .start ()
90111 except Exception as exc : # noqa: BLE001 - instrumentation must not escape hooks
91- self ._disabled = True
112+ # Leave the pending records in place: drain() exports them inline,
113+ # and a later schedule() retries starting a worker.
92114 self ._worker = None
93- failed_pending = self ._pending
94- self ._pending = None
95- failed_event = self ._flush_event
96- self ._flush_event = None
97- self ._flush_requested = False
98- if failed_event is not None :
99- failed_event .set ()
100- return failed_pending , exc
101- return None , None
115+ return exc
116+ return None
117+
118+ def _log_start_failure (self , start_error : Exception | None ) -> None :
119+ if start_error is None or self ._start_failure_logged :
120+ return
121+ self ._start_failure_logged = True
122+ _logger .warning (
123+ "workflow-insight: could not start export worker; records are "
124+ "exported inline at invocation end instead: %s" ,
125+ start_error ,
126+ )
127+
128+ def _pop_pending_locked (self ) -> dict [str , Any ] | None :
129+ if not self ._pending :
130+ return None
131+ key = next (iter (self ._pending ))
132+ return self ._pending .pop (key )
133+
134+ def _take_flush_locked (self ) -> threading .Event | None :
135+ flush_event = self ._flush_event
136+ self ._flush_event = None
137+ self ._flush_requested = False
138+ return flush_event
102139
103140 def _run (self ) -> None :
104141 while True :
105142 record : dict [str , Any ] | None = None
106143 flush_event : threading .Event | None = None
107144 with self ._condition :
108- while self ._pending is None and not self ._flush_requested :
145+ while not self ._pending and not self ._flush_requested :
109146 self ._condition .wait ()
110- if self ._pending is not None :
111- record = self ._pending
112- self ._pending = None
113- else :
114- flush_event = self ._flush_event
115- self ._flush_event = None
116- self ._flush_requested = False
147+ record = self ._pop_pending_locked ()
148+ if record is None :
149+ # Every pending record is exported: honor the flush request.
150+ flush_event = self ._take_flush_locked ()
117151
118152 if record is not None :
119153 self ._export (record )
@@ -123,10 +157,25 @@ def _run(self) -> None:
123157 if flush_event is not None :
124158 flush_event .set ()
125159 with self ._condition :
126- if self ._pending is None and not self ._flush_requested :
160+ if not self ._pending and not self ._flush_requested :
127161 self ._worker = None
128162 return
129163
164+ def _pump (self , flush_event : threading .Event ) -> None :
165+ """Export every pending record, then flush, on the calling thread."""
166+ while True :
167+ with self ._condition :
168+ record = self ._pop_pending_locked ()
169+ if record is None :
170+ # Another inline drain may already have taken the request;
171+ # flushing twice is harmless, losing a record is not.
172+ self ._take_flush_locked ()
173+ if record is None :
174+ break
175+ self ._export (record )
176+ self ._flush ()
177+ flush_event .set ()
178+
130179 def _export (self , record : dict [str , Any ]) -> None :
131180 for exporter in self ._exporters :
132181 try :
@@ -159,4 +208,4 @@ def _worker_alive(self) -> bool:
159208
160209 def _pending_count (self ) -> int :
161210 with self ._condition :
162- return int (self ._pending is not None )
211+ return len (self ._pending )
0 commit comments