Skip to content

feat(insight): add async export scheduling - #702

Open
wangyb-A wants to merge 6 commits into
mainfrom
feat/insight-async-export
Open

feat(insight): add async export scheduling#702
wangyb-A wants to merge 6 commits into
mainfrom
feat/insight-async-export

Conversation

@wangyb-A

@wangyb-A wangyb-A commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • move Workflow Insight rendering, truncation, export, and flush off the checkpoint thread
  • coalesce cumulative snapshots with one lazy daemon lane per exporter
  • drain final records and flush exporters with one shared configurable timeout
  • isolate exporter failures and bound pending state without failing workflows

Tracks #687.

Design

  • one lazy daemon worker per exporter
  • one in-flight record and latest pending record per execution ARN
  • FIFO fairness across execution ARNs
  • export_timeout_seconds defaults to 5 seconds and bounds drain plus flush
  • workers stop when idle; blocked workers are retained and never replaced
  • no core SDK changes

Validation

  • 113 Insight tests passed, including local-runner e2e
  • scheduler and async subset passed 12 consecutive runs
  • mypy passed
  • Ruff lint and format passed
  • wheel and sdist built
  • full repository collection: 3537 tests, no errors
  • Workflow Insight conformance PR feat: support CONTEXT operations in CheckpointedResult.create_from_op… #73: CloudWatch 18/18 and S3 18/18 (unchanged; record content, ordering, and timeout are unaffected by these changes)

Review decisions

Iteration Finding Decision Reason Validation
1 Deepcopy failure could alias records across lanes Fixed Violated exporter isolation Copy-failure and no-alias tests
1 Deepcopy fallback lacked tests Fixed Regression would be silent Focused tests repeated
1 _inflight_arn was dead state Fixed Misleading and unused Full suite and mypy
1 Bounded deque scans are O(n) Declined Queue capped at 1024; rewrite adds risk Cap and fairness tests
1 Warm timeout behavior lacked coverage Fixed Required approved behavior Deterministic A/B FIFO test
2 Global thread-count assertion could flake Fixed Daemon exit could alter baseline Lane-local checks; subset 12x
2 Product concurrency and lifecycle Accepted No product defects found Full reviewer trace
3 Full implementation after fixes Accepted No actionable findings Final reviewer pass
3 Timing assertions under severe host load Accepted risk Wide margins; 12 repeats passed Subset 12x
3 Shared flush can publish another execution buffer early Accepted by design Non-lossy shared lifecycle behavior Warm-container tests
public Cancelled flush barriers could accumulate behind a blocked exporter Fixed A stale barrier per warm invocation grew queue/barrier state without bound Repeated-timeout test + queued/already-popped cancellation-race tests; scheduler subset 12x; CloudWatch 18/18 and S3 18/18
public Lane used the default reentrant RLock Fixed Replaced with an explicit non-reentrant Lock; the lane never re-acquires _cond while holding it, so recursion support is unneeded and misuse now fails loudly Full insight suite; mypy; ruff; scheduler subset 12x
public Same exporter instance could be configured more than once Fixed WorkflowInsightConfig now rejects a duplicate exporter instance (by object identity, not equality/hash) with a clear ValueError; preserves one-thread-per-distinct-instance safety and avoids duplicate, timing-dependent scheduling. Distinct same-class instances and the default exporter are unaffected Tests: same instance twice raises; two distinct instances each get a lane; default exporter unaffected; full insight suite; mypy; ruff
public _ExecutionState.scheduled was read/written without the plugin lock Fixed (defensive) Route the flag through _lock via _mark_scheduled/_was_scheduled, consistent with the other state fields; the lock is released before any scheduler/end_invocation or exporter work, so no new lock ordering or deadlock. SDK serializes hooks, so scope is minimal Scheduled-flag and no-op-invocation tests; scheduler subset 12x

Reviewed three times with commit-code-reviewer, plus two post-public-review passes addressing the rows above. No actionable findings remain.

@wangyb-A
wangyb-A force-pushed the feat/insight-async-export branch from 0bbf504 to 6b73e82 Compare September 2, 2026 18:43
@wangyb-A

wangyb-A commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/ai review

Comment on lines +303 to +304
if not barrier.wait(remaining):
barrier.canceled = True

This comment was marked as outdated.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@wangyb-A

wangyb-A commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

/ai review

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@wangyb-A
wangyb-A marked this pull request as ready for review September 3, 2026 18:35
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime September 3, 2026 18:36 — with GitHub Actions Inactive
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime September 3, 2026 18:52 — with GitHub Actions Inactive
@wangyb-A
wangyb-A had a problem deploying to ai-pr-review-runtime September 3, 2026 18:52 — with GitHub Actions Failure
Comment on lines +345 to +348
# must omit endTime/durationMs. Passing end_time=None makes _emit
# drop both fields. Output and error likewise belong only to a
# terminal record.
self._emit(
self._schedule_record(

This comment was marked as outdated.

@github-actions

This comment has been minimized.

Yubo Wang added 6 commits September 3, 2026 21:41
Move all exporter work off the SDK checkpoint thread. A new private
_ExportScheduler owns one lazy daemon worker per exporter lane;
per-exporter copy, render, truncation, export() and flush() now run
there, so a slow exporter never blocks workflow progress.

Per lane: at most one in-flight record and one latest pending record
per execution ARN. Cumulative snapshots for the same ARN coalesce (the
in-flight record is never cancelled); updating a pending ARN moves it
to the back for FIFO fairness across ARNs; pending ARNs are capped with
oldest-eviction. A blocked worker is retained and never replaced, and
idle workers exit after the drain, so threads cannot grow unbounded.

on_operation_change returns immediately unless emit mode is on-change.
Invocation end schedules the final record, then drains and flushes the
touched lanes under one shared deadline; on timeout the workflow
response is returned and delivery degrades to best-effort. Exceptions
in render/export/flush are isolated and logged.

Add WorkflowInsightConfig.export_timeout_seconds (default 5.0),
validated as a finite number greater than zero (rejects bool, NaN,
infinity, and non-positive values).

Add scheduler, plugin-async, and config unit tests plus updated
on-change coalescing coverage; refresh the README note. No core SDK
changes.
Skip a lane record when copy.deepcopy fails instead of aliasing the
shared canonical record. The alias let this lane's truncation mutate
the object other lanes still read, breaking workflow isolation. A copy
failure is now logged through the module logger and the lane keeps
draining, matching render/truncation failure handling.

Also remove the dead _inflight_arn lane field (written, never read).

Tests: deepcopy-failure skips the record, does not call the exporter,
logs the failure, and the lane continues to export a later valid
record; a non-aliasing regression guards in-place mutation; a
warm-container cross-invocation test proves bounded invocation-end
waits, no A/B merge, and FIFO drain + flush after unblock.
Make the two shared-timeout tests wait deterministically for their
released lane workers to stop before returning, so their daemon workers
cannot exit between a later test's baseline capture and its assertion.

Replace the fragile process-global thread-count delta in
test_blocked_worker_is_not_replaced with lane-local worker identity,
aliveness, and a lane-scoped worker count. This proves the blocked lane
never spawns a replacement without depending on global thread state.

Product code is unchanged.
Cancelled flush barriers no longer pile up behind a blocked exporter.
end_invocation now pairs each barrier with its lane and, on timeout,
calls _ExporterLane.cancel_flush(barrier): under the lane lock it marks
the barrier cancelled and pulls its still-queued _FLUSH marker out,
completing it there. If the worker already popped the marker the flush
is left to the worker; an in-flight synchronous flush is not killed.
This keeps queue and barrier state bounded across many warm
invocations while preserving record ordering, normal flush, the shared
deadline, blocked-worker retention, and bounded pending state.

Also switch the lane Condition from the default RLock to an explicit
non-reentrant Lock; the lane never re-acquires _cond while holding it.

Tests: deterministic repeated-timeout test (blocked exporter across
many warm invocations) plus queued-vs-already-popped cancellation
race tests.
Reject the same exporter instance appearing more than once in
WorkflowInsightConfig.exporters with a clear ValueError, compared by
object identity (not equality/hash) during config normalization. Two
distinct instances of the same class stay valid and each keeps its own
lane; the default exporter is unaffected. Preserves the
one-thread-per-distinct-instance safety and avoids duplicate,
timing-dependent scheduling.

Route _ExecutionState.scheduled mutation and read through the plugin
_lock via _mark_scheduled/_was_scheduled, consistent with the other
state fields. The lock is released before any scheduler/end_invocation
or exporter work, so no new lock ordering or deadlock is introduced.

Add tests: same instance twice raises; two distinct same-class
instances each get a lane; default exporter unaffected; scheduled flag
tracks scheduling.
@wangyb-A
wangyb-A force-pushed the feat/insight-async-export branch from 9e0e717 to 2e7049c Compare September 3, 2026 21:41
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime September 3, 2026 22:17 — with GitHub Actions Inactive
@wangyb-A
wangyb-A temporarily deployed to ai-pr-review-runtime September 3, 2026 22:17 — with GitHub Actions Inactive
self._event.set()

def wait(self, timeout: float) -> bool:
return self._event.wait(timeout if timeout > 0 else 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review · Finding arf_v1_fpgb65yygmtxhv5a24c72e2brx

[P2] Bound timeouts to threading.TIMEOUT_MAX. Configuration currently accepts any finite positive float, but Event.wait() raises OverflowError above the platform limit. With a blocked exporter, the plugin exception is swallowed before lane shutdown and execution-state cleanup, silently bypassing the drain and retaining state. Reject or clamp oversized values during validation, handle huge integers that overflow math.isfinite, and add a blocked-exporter test.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Codex AI review

Found one timeout-validation edge case that can bypass exporter drain and plugin cleanup.

Reviewed commit 2e7049ca286f113cf303d8add7d0ed318f6db1e7. Workflow run

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude AI review

Reviewed the full diff for the Workflow Insight async-export feature: the new _export_scheduler.py (lane/worker/flush-barrier scheduler), the plugin.py rewiring of _emit into _schedule_record/_build_record plus the new scheduled gate, and the new WorkflowInsightConfig validation (export_timeout_seconds, exporter-instance identity check), along with the accompanying tests.

Traced the concurrency model in detail:

  • _ExporterLane's queue/pending invariants (a _pending entry always has a matching queued _RECORD token unless the arn is in flight) hold under all mutation paths (schedule, cap eviction, coalescing, worker pop), so _move_record_token_to_back/_remove_record_token can't silently no-op in a way that corrupts fairness or drops a record.
  • The shared record dict handed to every lane is never mutated before each lane's own copy.deepcopy in _export_one; truncate_record also never mutates its input, so cross-lane isolation holds even when a user render() mutates its argument.
  • _ExportScheduler.end_invocation's flush barrier is enqueued after any already-queued record token for that lane, so a successful (non-degraded) end_invocation() return is only reached after the corresponding export has actually run — this preserves the pre-existing synchronous-looking behavior for callers/tests, so the many unchanged tests in test_plugin.py that assert on exporter.records immediately after on_invocation_end() still hold correctly under the new async path.
  • The new on_operation_change early-return for non-ON_CHANGE modes is safe because on_invocation_end always re-adopts a fresh operations snapshot before building the terminal record, so skipping the mid-invocation adopt has no effect on the final record.
  • _ExecutionState.scheduled is consistently read/written through _mark_scheduled/_was_scheduled under the plugin lock, and the gate correctly avoids touching lanes for no-op invocations.
  • Cap eviction, cancelled-barrier cleanup, and worker lifecycle (lazy start, never-replace-a-blocked-worker, idle-stop) all match their documented invariants and are exercised by dedicated tests.

No correctness, determinism, thread-safety, or public-API defects were found in the changed code.

Residual test risk: the scheduler tests rely on wall-clock waits (_wait_until, short timeouts like 0.1–0.3s) and thread-name/count introspection to assert concurrency invariants; the PR description already reports repeated runs to guard against this, but such tests can still be more prone to flaking than deterministic tests under heavy CI/host load. Separately, the new exporter-instance identity check (WorkflowInsightConfig._validate_exporters) only guards against reusing the same exporter object within one config; it does not (and probably need not) guard against the same exporter instance being wired into two independent WorkflowInsightConfig/plugin instances, which would still create two lanes calling into a possibly non-thread-safe exporter concurrently — an unlikely but unguarded configuration.

Reviewed commit 2e7049ca286f113cf303d8add7d0ed318f6db1e7. Workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant