Add per-invocation observer event drain primitive#131
Merged
Conversation
CompiledGraph.drain_events_for(invocation_id, *, timeout=5.0) gives a terminal node a way to block until every event dispatched for the in-flight invocation has been delivered to every attached observer, then proceed -- typically followed by a read against a queryable observer accumulator's per-invocation bucket whose state the drain has now caught up to. Without this, the deliver loop may still hold not-yet-dispatched events at the moment the terminal node reads, and the read silently undercounts. The implementation hangs on the existing per-invocation _DrainCounters: drain_events_for snapshots dispatched at call time, registers a (target, Future) pair on a new drain_wakers list, and awaits the Future via asyncio.wait_for. The deliver loop fulfils any waker whose target has been reached after each delivered increment. On timeout the waker is removed from the list and the partial summary is returned -- and crucially, the worker is NOT cancelled. This is the load-bearing difference from the process-wide drain: drain is shutdown semantics and cancels its workers; drain_events_for is in-flight synchronization and leaves them running so the graph keeps serving other invocations. Subgraph descents share the parent's _DrainCounters, so a drain on the outermost invocation_id covers fan-out instance events and parallel-branches branch events for free. Ten unit tests mirror the spec fixtures' case shapes: basic synchronization (028), snapshot semantic / no-deadlock on the calling node's own completed event (029), worker-not-cancelled-on-timeout (030, the load-bearing divergence), invocation-scope isolation (031), fan-out coverage (032), parallel-branches coverage (033), plus zero-timeout non-blocking check, unknown id, negative + NaN boundary rejection. Conformance manifest 0054 flips to implemented since 0.12.0. The six graph-engine fixtures (028-033) stay deferred from the cross-capability parser pending the upcoming conformance-adapter capability spec to ratify the directive vocabulary. Coord context: discuss-per-invocation-event-drain thread, 02-spec-accepted-as-0054 settling the five spec questions (section, name, default timeout, summary shape, resume interaction) and flagging the worker-cancellation divergence explicitly.
There was a problem hiding this comment.
Pull request overview
Implements proposal 0054’s per-invocation observer event drain so terminal nodes can synchronize with the observer delivery loop before reading derived observer state.
Changes:
- Add
CompiledGraph.drain_events_for(invocation_id, *, timeout=5.0)with snapshot semantics and non-cancelling timeout behavior. - Extend observer delivery loop bookkeeping to wake per-invocation drain waiters once
deliveredreaches the snapshotted target. - Add unit tests and update conformance notes/docs/changelog to reflect the shipped primitive and deferred fixture parsing.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_drain.py | Adds unit tests for drain_events_for behaviors (sync, snapshot, timeout, scope, fan-out/branches). |
| tests/conformance/test_fixture_parsing.py | Updates deferral rationale for 028–033 fixture shapes pending parser support. |
| src/openarmature/graph/observer.py | Adds per-invocation drain waker list and deliver-loop wakeup logic. |
| src/openarmature/graph/compiled.py | Adds drain_events_for API and docs alongside existing drain(). |
| docs/concepts/observability.md | Updates observability docs to describe shipped primitive and timeout divergence from drain(). |
| conformance.toml | Marks proposal 0054 as implemented and documents pinned behaviors/tests. |
| CHANGELOG.md | Adds an “Added” entry documenting drain_events_for and semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Docstring fixes: the opening sentence said the drain covers events dispatched "before this call returns", but the snapshot is taken at call entry — events dispatched between entry and exit are not in scope. Rewords to "as of this call's entry". The snapshot- semantic paragraph also claimed the calling node's started AND completed events were out of scope, but the engine dispatches started immediately before the node body runs, so started is already in the snapshot and the drain awaits its delivery normally. Only completed is guaranteed out of scope. Test fixes: - test_drain_events_for_timeout_does_not_cancel_worker now runs the follow-up invocation on the SAME compiled graph as the timed-out one, and the decisive contract check is that all NodeEvents from the originally-pending queue land in the observer's delivery list AFTER the timed-out drain returned. The previous version compiled a fresh graph for the follow-up, so the test would have passed even if the original worker had been cancelled. - test_drain_events_for_invocation_scope_isolation now actually performs two serial invocations (the previous version invoked once and drained the same id twice, testing stale-id idempotency rather than per-invocation isolation). Each drain's delivery log entries are partitioned by invocation_id; the assertion is strict (no cross-contamination, no entries outside either partition).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR 2b of the v0.12.0 cycle. Implements proposal 0054 (spec graph-engine §6 Per-invocation drain, spec v0.46.0).
CompiledGraph.drain_events_for(invocation_id, *, timeout=5.0)gives a terminal node a way to block on the observer event stream before reading derived observer state. The classic pattern is the §9.4 queryable accumulator observer: events arrive on the deliver loop's queue and the accumulator's per-invocation bucket fills up as the loop processes them. A terminalpersistnode callingaccumulator.get_bucket(...)without first awaiting the drain can read a partially-filled bucket because the deliver loop is still racing the node body. The new primitive synchronizes those two: the node body pauses until every event dispatched up to call time has been delivered, then reads.Notable pieces
src/openarmature/graph/compiled.py. Validates timeout with the samenot (timeout >= 0)idiom asdrain(). Finds the worker context whoseinvocation_idmatches; if none, returns an empty summary (already drained or never started). Snapshotsdispatchedat call time. Fast path: ifdelivered >= target, returns immediately. Slow path: creates a Future on the running loop, registers a(target, fut)pair on_DrainCounters.drain_wakers, and awaits viaasyncio.wait_for. OnTimeoutError: removes the Future from the wakers list and returnsDrainSummary(undelivered_count=max(0, target - delivered), timeout_reached=True).src/openarmature/graph/observer.py. Aftercounters.delivered += 1, the loop walksdrain_wakersand fulfills any whose target has been reached. Guarded byif counters.drain_wakers:so the common-case (no in-flight drains) takes zero extra work.not fut.done()absorbs the race whereasyncio.wait_forhas already cancelled the Future on timeout._DrainCountersfield:drain_wakers: list[tuple[int, asyncio.Future[None]]]. Subgraph descents (fan-out instances, parallel-branches branches) share the parent's counters via the existingdescend_into_subgraphplumbing, so a drain on the outermostinvocation_idcovers all inner events without extra wiring.drain(): per-invocation drain leaves the worker running on timeout.drainis shutdown semantics and cancels its workers;drain_events_foris in-flight synchronization and the graph keeps serving other invocations. Documented in the method docstring and pinned bytest_drain_events_for_timeout_does_not_cancel_worker.Behavior pins
Ten unit tests in
tests/unit/test_drain.pymirror the spec fixtures' case shapes:test_drain_events_for_basic_synchronization(fixture 028)test_drain_events_for_snapshot_semantic_does_not_wait_for_own_completed_event(fixture 029): node body callsdrain_events_for(current_invocation_id)from inside itself; outerasyncio.wait_for(invoke(...), timeout=5.0)would fire if the snapshot semantic were broken.test_drain_events_for_timeout_does_not_cancel_worker(fixture 030, the load-bearing divergence): tight timeout against very-slow observer; verifies the deliver loop still processes a fresh second invocation cleanly after the timed-out drain returns.test_drain_events_for_invocation_scope_isolation(fixture 031)test_drain_events_for_covers_fan_out_instance_events(fixture 032): 3-instance fan-out; drain on outer id awaits all three instance subgraph events.test_drain_events_for_covers_parallel_branches_events(fixture 033)test_drain_events_for_zero_timeout_is_non_blocking_checktest_drain_events_for_unknown_invocation_returns_clean_summarytest_drain_events_for_rejects_negative_timeout,test_drain_events_for_rejects_nan_timeoutThe six spec fixtures (graph-engine/028-033) stay deferred from the cross-capability parser pending the upcoming conformance-adapter capability to ratify the new directive vocabulary (
observers[].behavior: accumulate,nodes.<name>.invoke_drain_events_for, the per-nodenode_drain_summaries/node_accumulator_snapshotsassertion blocks).Conformance + docs
conformance.toml:[proposals."0054"]flipsnot-yet→implemented/since="0.12.0". Comment block enumerates the ten pin tests and the worker-cancellation-divergence rationale.tests/conformance/test_fixture_parsing.py: 028-033 deferral rationale updates from "lands in PR 2b" to "fixture-shape models pending; contract pinned by unit tests".docs/concepts/observability.md: the!!! info "drain_events_for ships in v0.12.0..."callout (added in PR 129 as a future-promise hedge) disappears now that the primitive ships. The surrounding text grows a paragraph explaining the worker-cancellation divergence and the timeout discipline.CHANGELOG.md:### Addedunder[Unreleased]gets adrain_events_forentry positioned before the existingget_invocation_metadata()entry from PR 129.src/openarmature/AGENTS.md: regenerated.Coord context
Spec coord thread
discuss-per-invocation-event-drain(02-spec-accepted-as-0054.md) cleared all five spec questions: §6 section placement,drain_events_fornaming, no-mandated-default timeout (free to pick5.0), reuseDrainSummaryshape verbatim, and the resume interaction now covered by proposal 0021 (suspension). Spec also flagged the worker-cancellation divergence explicitly as a thing the Python implementation could regress on if shared withdrain()'s code path — pinned bytest_drain_events_for_timeout_does_not_cancel_worker.Test plan
uv run pytest tests/— 1111 passed (was 1101 pre-PR), 307 skipped, 0 failuresuv run pytest tests/unit/test_drain.py— 19 passed (was 9; +10 new)uv run pytest tests/unit/test_observer.py tests/unit/test_fan_out.py— no regressions on the touched subsystemuv run python scripts/check_conformance_manifest.py— 51/51 entries consistentuv run ruff check .+uv run ruff format --check .— cleanuv run pyright src/ tests/— 0 errorsuv run mkdocs build --strict— clean