Skip to content

Commit cd7eabb

Browse files
author
Alex Wang
committed
feat(insight): export records asynchronously
1 parent 630b777 commit cd7eabb

7 files changed

Lines changed: 442 additions & 32 deletions

File tree

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,13 @@ and `top-level` vs `full-tree` operation detail all mirror the JS plugin.
5555
Behavior is validated cross-SDK by the `insight` conformance suite
5656
(`aws-durable-execution-conformance-tests-insight`).
5757

58-
> **Note (`on-change` emission).** In `on-change` mode, exporter calls currently
59-
> run synchronously on the SDK checkpoint path, so a slow exporter can delay
60-
> workflow progress. Asynchronous scheduling/coalescing is deferred and tracked
61-
> in [issue #687](https://github.com/aws/aws-durable-execution-sdk-python/issues/687).
58+
> **Note (asynchronous export).** Export rendering, truncation, `export()`, and
59+
> `flush()` run on one lazy background worker per plugin. Checkpoint hooks only
60+
> replace the latest pending snapshot and wake the worker. Consecutive
61+
> `on-change` snapshots may coalesce while an export is in flight. Invocation
62+
> end waits up to `WorkflowInsightConfig.export_timeout_seconds` (default 5
63+
> seconds) for the latest snapshot and exporter flush; after timeout delivery is
64+
> best-effort.
6265
6366
## Requirements
6467

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# SPDX-FileCopyrightText: 2026-present Amazon.com, Inc. or its affiliates.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""Latest-pending asynchronous export scheduling for Workflow Insight."""
5+
6+
from __future__ import annotations
7+
8+
import logging
9+
import threading
10+
from typing import Any
11+
12+
from aws_durable_execution_sdk_python_insight.truncation import truncate_record
13+
from aws_durable_execution_sdk_python_insight.types import InsightExporter
14+
15+
16+
_logger = logging.getLogger("aws_durable_execution_sdk_python_insight")
17+
18+
19+
class _ExportScheduler:
20+
"""Run all exporters on one lazy worker with one latest pending record."""
21+
22+
def __init__(self, exporters: list[InsightExporter]) -> None:
23+
self._exporters = exporters
24+
self._condition = threading.Condition(threading.Lock())
25+
self._pending: dict[str, Any] | None = None
26+
self._flush_requested = False
27+
self._flush_event: threading.Event | None = None
28+
self._worker: threading.Thread | None = None
29+
self._disabled = False
30+
31+
def schedule(self, record: dict[str, Any]) -> None:
32+
"""Replace the pending snapshot and return without running exporters."""
33+
displaced: dict[str, Any] | None = None
34+
failed_pending: dict[str, Any] | None = None
35+
start_error: Exception | None = None
36+
with self._condition:
37+
if self._disabled:
38+
return
39+
displaced = self._pending
40+
self._pending = record
41+
failed_pending, start_error = self._ensure_worker_locked()
42+
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+
)
51+
52+
def drain(self, timeout_seconds: float) -> bool:
53+
"""Wait for pending export and one flush, bounded by ``timeout_seconds``."""
54+
failed_pending: dict[str, Any] | None = None
55+
start_error: Exception | None = None
56+
with self._condition:
57+
if self._disabled:
58+
return False
59+
if not self._flush_requested:
60+
self._flush_requested = True
61+
self._flush_event = threading.Event()
62+
flush_event = self._flush_event
63+
assert flush_event is not None
64+
failed_pending, start_error = self._ensure_worker_locked()
65+
started = not self._disabled
66+
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 not started:
75+
return False
76+
completed = flush_event.wait(timeout_seconds)
77+
if not completed:
78+
_logger.warning(
79+
"workflow-insight: export drain/flush exceeded %.3fs; "
80+
"record delivery is best-effort",
81+
timeout_seconds,
82+
)
83+
return completed
84+
85+
def _ensure_worker_locked(
86+
self,
87+
) -> tuple[dict[str, Any] | None, Exception | None]:
88+
if self._worker is not None and self._worker.is_alive():
89+
return None, None
90+
worker = threading.Thread(
91+
target=self._run,
92+
name=f"workflow-insight-export-{id(self)}",
93+
daemon=True,
94+
)
95+
self._worker = worker
96+
try:
97+
worker.start()
98+
except Exception as exc: # noqa: BLE001 - instrumentation must not escape hooks
99+
self._disabled = True
100+
self._worker = None
101+
failed_pending = self._pending
102+
self._pending = None
103+
failed_event = self._flush_event
104+
self._flush_event = None
105+
self._flush_requested = False
106+
if failed_event is not None:
107+
failed_event.set()
108+
return failed_pending, exc
109+
return None, None
110+
111+
def _run(self) -> None:
112+
while True:
113+
record: dict[str, Any] | None = None
114+
flush_event: threading.Event | None = None
115+
with self._condition:
116+
while self._pending is None and not self._flush_requested:
117+
self._condition.wait()
118+
if self._pending is not None:
119+
record = self._pending
120+
self._pending = None
121+
else:
122+
flush_event = self._flush_event
123+
self._flush_event = None
124+
self._flush_requested = False
125+
126+
if record is not None:
127+
self._export(record)
128+
continue
129+
130+
self._flush()
131+
if flush_event is not None:
132+
flush_event.set()
133+
with self._condition:
134+
if self._pending is None and not self._flush_requested:
135+
self._worker = None
136+
return
137+
138+
def _export(self, record: dict[str, Any]) -> None:
139+
for exporter in self._exporters:
140+
try:
141+
shaped = truncate_record(
142+
record, exporter.max_record_size_bytes, exporter.render
143+
)
144+
exporter.export(shaped)
145+
except Exception as exc: # noqa: BLE001 - one exporter must not break others
146+
_logger.warning(
147+
"workflow-insight: exporter %s failed: %s",
148+
type(exporter).__name__,
149+
exc,
150+
)
151+
152+
def _flush(self) -> None:
153+
for exporter in self._exporters:
154+
try:
155+
exporter.flush()
156+
except Exception as exc: # noqa: BLE001 - one exporter must not break others
157+
_logger.warning(
158+
"workflow-insight: exporter %s flush failed: %s",
159+
type(exporter).__name__,
160+
exc,
161+
)
162+
163+
# Test helpers.
164+
def _worker_alive(self) -> bool:
165+
with self._condition:
166+
return self._worker is not None and self._worker.is_alive()
167+
168+
def _pending_count(self) -> int:
169+
with self._condition:
170+
return int(self._pending is not None)

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

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
import datetime
3434
import json
3535
import math
36-
import sys
3736
import threading
3837
from typing import Any, Callable
3938

@@ -47,10 +46,10 @@
4746
OperationType,
4847
)
4948

49+
from aws_durable_execution_sdk_python_insight._export_scheduler import _ExportScheduler
5050
from aws_durable_execution_sdk_python_insight.exporters.lambda_log_exporter import (
5151
LambdaLogExporter,
5252
)
53-
from aws_durable_execution_sdk_python_insight.truncation import truncate_record
5453
from aws_durable_execution_sdk_python_insight.types import (
5554
ContentConfig,
5655
EmitMode,
@@ -205,6 +204,8 @@ def __init__(self, config: WorkflowInsightConfig) -> None:
205204
self._exporters: list[InsightExporter] = (
206205
list(config.exporters) if config.exporters else [LambdaLogExporter()]
207206
)
207+
self._export_timeout_seconds = config.export_timeout_seconds
208+
self._scheduler = _ExportScheduler(self._exporters)
208209
self._state: dict[str, _ExecutionState] = {}
209210
self._lock = threading.Lock()
210211

@@ -323,6 +324,8 @@ def on_invocation_end(self, info: InvocationEndInfo) -> None:
323324
error=info.error if is_terminal else None,
324325
)
325326

327+
self._scheduler.drain(self._export_timeout_seconds)
328+
326329
# Clear state after EVERY invocation end, including PENDING/RETRY. The
327330
# next invocation rebuilds it from InvocationStartInfo.operations, so a
328331
# suspended execution that never resumes in this environment (or that was
@@ -435,21 +438,7 @@ def _emit(
435438
record["error"] = {"name": error.type, "message": error.message}
436439
record["operations"] = self._build_operations(operations)
437440

438-
for exporter in self._exporters:
439-
try:
440-
shaped = truncate_record(
441-
record, exporter.max_record_size_bytes, exporter.render
442-
)
443-
exporter.export(shaped)
444-
except Exception as exc: # noqa: BLE001 - one exporter must not break others / the execution
445-
# NOTE (parity gap, same as JS Promise.allSettled): exporter
446-
# failures are swallowed so instrumentation never breaks the
447-
# execution. A silently broken exporter is indistinguishable
448-
# from success; we at least log to stderr.
449-
print(
450-
f"[workflow-insight] exporter {type(exporter).__name__} failed: {exc}",
451-
file=sys.stderr,
452-
) # noqa: T201
441+
self._scheduler.schedule(record)
453442

454443

455444
def workflow_insight(config: WorkflowInsightConfig) -> WorkflowInsightPlugin:

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from __future__ import annotations
1313

14+
import math
15+
import threading
1416
from dataclasses import dataclass, field
1517
from enum import StrEnum
1618
from typing import Any, Callable, Literal, Protocol
@@ -106,6 +108,7 @@ class WorkflowInsightConfig:
106108
emit_mode: EmitMode | EmitModeInput | None = None
107109
operation_detail: OperationDetail | OperationDetailInput | None = None
108110
content: ContentConfig | None = None
111+
export_timeout_seconds: float = 5.0
109112

110113
def __post_init__(self) -> None:
111114
# Normalize accepted string inputs to enum members so the plugin always
@@ -119,3 +122,20 @@ def __post_init__(self) -> None:
119122
object.__setattr__(
120123
self, "operation_detail", OperationDetail(self.operation_detail)
121124
)
125+
timeout = self.export_timeout_seconds
126+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
127+
raise ValueError("export_timeout_seconds must be a number")
128+
try:
129+
normalized = float(timeout)
130+
except (OverflowError, TypeError, ValueError) as exc:
131+
raise ValueError("export_timeout_seconds must be a finite number") from exc
132+
if (
133+
not math.isfinite(normalized)
134+
or normalized <= 0
135+
or normalized > threading.TIMEOUT_MAX
136+
):
137+
raise ValueError(
138+
"export_timeout_seconds must be finite, greater than zero, and "
139+
f"no greater than threading.TIMEOUT_MAX ({threading.TIMEOUT_MAX})"
140+
)
141+
object.__setattr__(self, "export_timeout_seconds", normalized)

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from __future__ import annotations
1313

14+
import threading
15+
1416
import pytest
1517

1618
from aws_durable_execution_sdk_python_insight import (
@@ -120,3 +122,30 @@ def test_readme_usage_call_shape_constructs_plugin():
120122
)
121123
plugin = workflow_insight(WorkflowInsightConfig(exporters=[exporter]))
122124
assert plugin._exporters == [exporter]
125+
126+
127+
# -- asynchronous export timeout ---------------------------------------------
128+
129+
130+
def test_export_timeout_default_and_normalization():
131+
assert WorkflowInsightConfig().export_timeout_seconds == 5.0
132+
assert WorkflowInsightConfig(export_timeout_seconds=2).export_timeout_seconds == 2.0
133+
134+
135+
@pytest.mark.parametrize(
136+
"value",
137+
[
138+
0,
139+
-1,
140+
float("nan"),
141+
float("inf"),
142+
True,
143+
"5",
144+
None,
145+
10**1000,
146+
threading.TIMEOUT_MAX * 2,
147+
],
148+
)
149+
def test_export_timeout_rejects_invalid_values(value):
150+
with pytest.raises(ValueError):
151+
WorkflowInsightConfig(export_timeout_seconds=value)

0 commit comments

Comments
 (0)