Publish flow-run state events after their transaction commits - #22655
Publish flow-run state events after their transaction commits#22655devin-ai-integration[bot] wants to merge 4 commits into
Conversation
Flow-run state change events were emitted inline during orchestration, before the transaction writing the state committed, so subscribers could receive an event and still read the previous state, and rolled-back transitions emitted events anyway. Build the event as before but defer publishing to a post-commit hook on the session, so the event is published once the state is committed and readable from another session. Closes #22654 Co-authored-by: Alex Streed <desertaxle@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0d9e7eee6a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
SQLAlchemy emits after_commit when a savepoint is released, which is not durable until the enclosing transaction commits. Co-authored-by: Alex Streed <desertaxle@users.noreply.github.com>
Merging this PR will not alter performance
Comparing Footnotes
|
desertaxle
left a comment
There was a problem hiding this comment.
Could Docket run these post-commit hooks instead of awaiting them inside SQLAlchemy's after_commit callback? A registered task could provide retries and observability, but we'd need to preserve ephemeral operation and define enqueue failure after commit.
|
|
||
|
|
||
| def _discard_hooks(session: Session, previous_transaction: SessionTransaction) -> None: | ||
| if previous_transaction.nested: |
There was a problem hiding this comment.
Returning here keeps hooks registered inside the rolled-back savepoint. The outer commit then drains them, so a reverted transition still emits an event. Can you track hook ownership by transaction scope and test nested rollback?
There was a problem hiding this comment.
You're right, fixed in de92bd5. Each hook is now stored with the transaction that was active when it was registered (get_nested_transaction() or get_transaction()), and after_soft_rollback drops every hook whose owning transaction is the one that rolled back or a descendant of it. So a savepoint rollback discards only the hooks registered inside it, and an outer rollback still discards everything.
Two tests: test_hooks_are_discarded_when_their_savepoint_rolls_back (fails on the previous commit) and test_hooks_survive_the_rollback_of_a_later_savepoint.
Hooks registered inside a savepoint that is rolled back were left queued and drained by the enclosing commit, so a reverted transition still emitted an event. Co-authored-by: Alex Streed <desertaxle@users.noreply.github.com>
It could — the hook body would become a registered task taking the already-built
Happy to do it as a follow-up if you want the retry/observability story — a transactional outbox drained by a docket task is probably the version that actually buys durability. Want me to open an issue for it, or fold it into this PR? |
Fold it into this PR. |
OSS-8109 The post-commit hook now hands the event to docket instead of emitting it itself, so publishing gets a task's retries and observability. When there's no docket to hand it to - the orchestration engine driven directly against a session, outside a running server - the event is still published in-line as the commit unwinds. Co-authored-by: Alex Streed <desertaxle@users.noreply.github.com>
|
Done in bb45f4d. The post-commit hook now enqueues a docket task instead of emitting the event itself:
Consequence worth flagging: state events are now published by a worker, so per-run delivery order is best-effort. Trigger evaluation and the task-run recorder already handle that through Tests in |
closes #22654
InstrumentFlowRunStateTransitions.after_transitionpublished the flow-run state change event inline, while orchestration rules were unwinding insideset_flow_run_state— i.e. before the API route'sdb.session_context(begin_transaction=True)committed. A subscriber could receiveprefect.flow-run.Suspendedand still read the run asRunning, and a transition that was rolled back emitted an event anyway.This PR builds the event exactly as before (it needs the live session to resolve related resources) but publishes it from a docket task queued as the transaction commits:
call_after_commit(server/utilities/_post_commit.py) queues hooks on the session and drains them from SQLAlchemy'safter_commitevent. That event fires inside the greenlet the async session used to commit, sosqlalchemy.util.await_onlyruns each hook as a coroutine on the original event loop — no cross-loop hand-off and no fire-and-forget task. Hooks belong to the transaction that was active when they were registered, so rolling back a transaction (or a savepoint) discards its hooks, and hook errors are logged rather than failing the commit.publish_after_commit(server/events/_publishing.py) then hands the event to docket rather than emitting it itself, so publishing gets a task's retries and observability:Details
POST /flow_runs/{id}/set_state) and bulk (POST /flow_runs/bulk_set_state) transitions both funnel throughmodels.flow_runs.set_flow_run_state, so both are fixed centrally, along with internal callers (services, event actions) that wrap the call in a session context.session.begin_nested()) are handled explicitly: releasing one dispatchesafter_commitbut isn't durable, so hooks stay queued until the enclosing transaction commits; rolling one back discards only the hooks registered inside it.publish_eventis registered inserver/api/background_workers.pywithRetry(attempts=5, delay=0.5s). Its key is the event id, so a duplicate enqueue for the same event dedupes.server/utilities/_docket.pygives code with neither a request nor a task context access to the docket:background_worker()serves the docket it runs, andget_docket()prefers docket's owncurrent_docketwhen we're already inside a task. Ephemeral servers get this for free — their lifespan starts a worker on thememory://docket.docket.additself happens after the commit, so a failure there is logged and dropped rather than reverting the committed transition (durable enqueue would need an outbox row written inside the transaction, which this PR doesn't add). With no docket at all — orchestration driven directly against a session, outside a running server — the event is published in-line, still after the commit.follows/causal ordering, but websocket subscribers can now see two events for the same run out of order.tests/server/orchestration/test_flow_run_instrumentation_policies.py::TestPublishingEventsAfterCommit— nothing is published until the commit; a rolled-back transition publishes nothing; for both the single and bulk API routes, reading the run from a separate session at publish time sees the committed state (before the fix the bulk case even deadlocked on SQLite's writer lock, since the emit happened while the write transaction was open).tests/events/server/test_publishing.py— the commit only enqueues the task, a rollback enqueues nothing, a failed enqueue doesn't fail the commit, and the no-docket path publishes in-line.tests/server/utilities/test_post_commit.py— ordering, single delivery, rollback and savepoint-rollback discarding, and error isolation for the hook utility.AssertingEventsClient, and pass unchanged.Checklist
mint.json.Link to Devin session: https://app.devin.ai/sessions/b48782b353cd420f8338ce44c7c66b44