- Workflows
- Run outcomes after commit
Run outcomes after commit
A run outcome is one database write. The run row changes to completed, failed or cancelled, and from that moment the outcome is final. Everything Ironflow does next is follow-up work. It tells waiting callers, wakes a parent run, publishes run events, releases capacity and writes audit rows.
Follow-up work runs after the commit, not inside it. A node can die between the two, a NATS publish can fail, and a worker can send its report twice. This page lists what each follow-up guarantees in those cases and what recovers it.
The short version:
- The run row is the source of truth. A committed outcome is never lost and never replaced by a later report.
- A run that waits on a child (
step.invoke) is woken once with the child’s committed outcome, even when the node that committed it dies before the wake. TriggerSyncandInvokeFunctionSyncreturn the committed outcome even when its NATS frame is lost.- Run and step events are best effort. A consumer that must not miss an outcome reads the run.
For how Ironflow recovers a run that never reached an outcome, see Crash recovery.
What follows a commit
Section titled “What follows a commit”| Follow-up | Who uses it | If the node dies or the publish fails | Duplicates | What recovers it |
|---|---|---|---|---|
RESULTS frame on ironflow.{project}.{env}.results.run.{id} | TriggerSync, InvokeFunctionSync | Lost. Logged and counted in ironflow_run_result_publish_failures_total. The caller still sees success. | JetStream drops a second terminal frame for the run (msg-id result-{runID}) inside its 2 minute window. | The waiting call reads the stored run every 100 ms, so a lost frame costs latency, not the result. |
| Parent wake | A parent run waiting in step.invoke | Lost. The wake runs in its own goroutine after the commit and is not part of the shutdown drain. | A second wake changes nothing. It updates the invoke step only while the step is still waiting and linked to this child, and a parent that already has a queued continuation is not queued again. | The scheduler’s waiting-run pass finds the waiting invoke step and the finished child, and wakes the parent. |
Run and step events on public.{env}.system.run.{id}.{event} | Dashboard, SDK run subscriptions, A2A streams | Lost. Counted, and retained in Dead Letters without a payload, so nothing can send it again. | Each publish gets a new message ID, so a duplicate event can reach a subscriber. | Nothing. Consumers refetch the run. |
| Capacity release (lease and worker credit) | The concurrency lane | Push: released in the same transaction as the outcome. Pull: released after the commit, so a crash leaves the lease held. | Not applicable. | The lease expires (90 s) and the capacity scanner frees the slot. It does not run a terminal run again. |
| Audit rows and Prometheus metrics | Operators | Lost. Each is its own write or in-memory counter. A workflow or capacity audit write that cannot finish in 5 s, or arrives over the limits below, is dropped and counted in ironflow_audit_write_failures_total. | A step report that is sent again can write a second audit row. | Nothing. |
cancel_on cleanup | The scheduler’s cancel matching | The run’s specs stay. | Not applicable. | A later event that matches one of them finds the run finished and deletes its specs. |
None of these follow-ups goes through the outbox. The outbox carries the events your code emits, entity events and file events, not lifecycle events about runs.
A parent run is woken once
Section titled “A parent run is woken once”A child run started by step.invoke links back to the parent’s invoke step. When the child reaches an outcome, the parent wake copies the child’s output, or its failure, onto the invoke step and queues the parent to continue.
If the node dies after the child’s commit but before the wake, the parent is left waiting with an invoke step that still says waiting. Every node’s scheduler scans waiting runs every 10 scheduler ticks (10 s with the default 1 s interval), 100 runs per pass. When it finds an invoke step whose linked child has finished, it runs the same wake. A cancelled child wakes its parent with a failed invoke step, the same as a failed child.
The wake cannot fire wrongly:
- It updates the invoke step only while that step is
waiting, belongs to the child’s parent run and links to this child. A step that timed out first stays timed out. - The parent continues only from
waiting, and only when it has no queued continuation already. - The waiting-run pass skips a parent whose execution still holds a lease. A wake that comes straight from the child’s commit does not wait. It finishes the invoke step and queues the parent’s continuation, which dispatch starts only after that lease is released.
A lost wake is recovered by the next pass that reads the parent. Each pass reads the next page of 100 waiting runs, so the delay grows with the number of waiting runs.
A late or repeated report does not change the outcome
Section titled “A late or repeated report does not change the outcome”Workers retry. Networks drop responses. A node can freeze long enough for its lease to expire and another node to recover the run. None of these can commit a second outcome:
- Push. The terminal write and the lease release are one fenced transaction. A response from a superseded execution is rejected (
STALE_EXECUTION), and a response for a run that is already finished only releases its lease. In both cases the engine sends no frame, event or wake. - Pull over REST. The engine reads the run, checks the request’s lease against the database, then writes the outcome with a version check. A stale lease gets
409 STALE_EXECUTION. A recovery that lands between the check and the write bumps the run’s version, so the write fails. - Pull over a streaming (gRPC) connection. The engine follows the same order. It reads the run, checks the lease in the database, then writes with a version check. A report from an execution whose lease the scanner recovered is dropped and counted in
ironflow_capacity_stale_mutations_rejected_total. So is a report that names an earlier execution of a run this node has already delivered again, even when it arrived before the new delivery. So is a report for a job this node no longer tracks, such as one a worker resends after reconnecting. - A retried report for a finished run. The engine reads the run, sees an outcome and returns without any follow-up. A retry that still carries the run as it was before the first commit fails the version check.
A cancel and a completion that race are decided by the same row. Whichever commits first wins, and the other changes nothing and announces nothing.
Shutdown
Section titled “Shutdown”Engine shutdown waits for in-flight dispatches it admitted, then stops. A child that a parent invokes after shutdown begins is queued like other waiting work. It can start on that node until the node’s dispatcher stops, and a request that shutdown cuts off is queued again with the same attempt, so the endpoint can receive it twice. Otherwise it runs after the restart or on another node. The invoke’s timeout keeps running meanwhile. Once a pull outcome’s write starts, the write, its frame and the parent wake run on a context that the caller’s cancel does not reach, so shutdown does not cut them off.
Streaming workers get 25 s to disconnect. A report that arrives after that fails before its write, and the run keeps its lease. When the lease expires, the scanner queues the run again, so the work runs a second time.
A parent wake still running when the process exits is lost. The waiting-run pass recovers it on the next start, as for a crash.
The follow-up of a cancel_on cancel and of an EXECUTION_RECOVERY_EXHAUSTED failure runs in its own goroutine, off the event router and the capacity scanner, so a slow write in it cannot hold up either loop or stop them shutting down. The scanner’s lease audit rows run the same way. A step read that uses up its 5 s limit costs the RESULTS frame its step summaries, not the frame.
The event router and the scanner keep committing outcomes for part of shutdown. So engine shutdown stops tracking their follow-ups last, once the scanner has stopped and the engine’s context is cancelled, and waits for the ones running. That wait shares the 30 s dispatch drain, and always gets at least 5 s of its own. Three things can still run after Engine.Stop returns, and the process exiting can lose them, as for a crash:
- A follow-up that outlasts that wait. Shutdown logs a warning.
- The follow-up of an outcome from a router delivery or a scheduler sweep already under way when tracking stopped. Its frame and wake are still attempted, with a warning, and its audit rows are dropped if
Engine.Stophas already returned. - A parent wake, which runs in its own goroutine.
- An admission’s follow-up that outlasts that wait, or starts after it. Its rows are dropped once
Engine.Stophas returned. With the store still open its replay, dispatch and resume still commit, and the queued segments run on the next start. With the store closed they fail with an error and are logged, and the waiting-run pass recovers them on the next start.
None of these is unsafe, but one can delay exit. Closing the PostgreSQL pool waits for connections in use, so a statement with no deadline stuck on a locked table, in an admission’s slot or a parent wake, holds the process until the statement ends or the process is killed.
A stuck store cannot take the pool
Section titled “A stuck store cannot take the pool”A stuck table holds a connection for every call waiting on it, so a burst of follow-ups could take the whole pool and stall other runs and the scanner. Three limits stop that, each a quarter of the PostgreSQL pool, or 4 on SQLite.
Audit writes take a slot in AuditRecorder, which every workflow and capacity audit write passes through, whichever path wrote it, and so does each read of a function’s recording setting: a lookup when it is not cached, and each refresh. Audit writers outside the recorder (auth, debounce, log level) are not capped.
- A write has 5 s to get a slot and finish, including any recording lookup. If it cannot, it is dropped and counted in
ironflow_audit_write_failures_total. - At most 1024 writes and lookups can be running or waiting. Past that a write is dropped and counted at once.
- When
Engine.Stopreturns, every write running or waiting is ended, dropped and counted, and so is every later write. - Every outcome’s frame, run event and parent wake go out before its audit row, except a run nobody announces. A parent wake resumes the parent before it writes the step’s audit row.
- A completion or failure writes its
run.status_changedrow off the goroutine that reported it, so neither the worker’s report nor its lease waits for the row. The same holds for acancel_oncancel and a recovery failure. A user cancel’s call does wait for the row, and so do a failed dispatch, the scheduler’s reclaim pass and the orphan sweep, one row at a time, after the frame has gone out. - Because each row takes its timestamp when it is inserted, a status row delayed by a stuck table can sort after a later operator resume of the same run.
Two kinds of row on the way to an outcome still delay work when the table is stuck, each by up to 5 s:
- Step rows. A step’s rows are written as the step is saved, before the run commits. A report with many steps on a function that records steps can delay the commit, and so the frame and the wake, by up to 5 s per row. These rows stay on the reporter’s goroutine because that keeps them in order, and it holds a fast reporter back instead of letting it pass the 1024 limit and lose rows on a healthy database.
run.createdrows. The row is written on the dispatch goroutine, just before the dispatch. It takes the run’s creation time, not the time of the write, so it sorts first even when acancel_onevent in the 30 s replay buffer cancels the run before the row lands. The time is capped at the store’s clock, so a node clock ahead of the store can still sort the row afterrun.cancelled, and time travel keeps the later status. An invoked child’s row is written before that replay too. A stuck table delays the run’s start. It does not hold the event router, the cron loop or the caller that created the run, except when the engine is shutting down and the run is failed instead of dispatched, and except for a fire-and-forget invoke on the invoke path, whose report waits up to 1 s for the row. Past that the replay can run ahead of the row.
Rows for a change that does not end a run also stay on their caller’s goroutine: the row for a run starting, a retry, a pause or a sleep or wait yield. So a stuck table can delay a run’s start by its run.created row and then its running row, and can hold a sleeping or waiting run’s report for its rows. A streaming worker’s reports run one at a time on its receive loop, with its heartbeats, so that hold also delays the connection’s other reports and heartbeats, and reports queued behind it add up toward the 90 s heartbeat timeout.
An accepted step.invoke report writes no audit row on the report. It commits, creates the child if it can, and returns. Then, off the report and in this order, come the parent’s step and waiting rows, its step.completed row for a fire-and-forget invoke, the child’s run.created row, the cancel_on replay, the child’s dispatch and, for a fire-and-forget invoke, the parent’s resume. A stuck table delays those by up to 5 s per row and does not hold the worker.
- A report’s return does not mean the parent has been queued again, or that its rows have landed.
- A crash after the report’s commit loses the rows. The waiting-run pass dispatches the child and resumes the parent.
- These rows no longer hold a fast reporter back, so a burst of invoke reports can pass the 1024 limit on a healthy database and lose rows. See below.
The waiting-run pass does the same for an invoke a report could not admit, such as one accepted during a freeze. After a thaw it admits each child on its loop and reads what it committed there, then writes the rows and does the rest off the loop, on a context that a stopping engine cannot end. So a stuck table does not hold sleep wakeups, and a read that fails after the admission’s commit does not cost its rows: they are written from what the commit wrote. A crash still does, because once the step links its child no pass writes them again.
An invoked child replays cancel_on and is read again before it is dispatched, because later replay cannot undo what a fast child did. That holds for a child created on the invoke path, on a store without native pending invocations or for a report without a lease. It also holds on an accepted report’s follow-up, on the waiting-run pass, on a retried yield that finds its child already created, and when the orphan sweep re-admits a child its parent’s step links. The pass and the sweep do not wait for whoever won the admission, whose rows can be stuck or who can be gone. So either can write run.cancelled or dispatch the child before a stuck run.created row lands. The row still sorts first, because it takes the run’s creation time. The replay reads the 30 s buffer and then the event log up to the database’s clock, so it also holds after a restart and for an event another node took.
What this does not cover:
- More than 1000 rows. The log read stops after about 1000 rows per
cancel_onspec. Past that the child is dispatched, and the reconcile pass (ADR 0136) repairs a cancel it missed, after the child may have started. - An event after the replay. It cancels through the live match or the reconcile pass, and the child may have started by then.
- Other runs the orphan sweep re-admits. A triggered or cron run that the sweep re-admits gets no replay at that point.
A store error stops the dispatch if it comes from the spec read, the log read, a cancel the log read matched, or the second read of the child. The report’s follow-up then leaves the child to the waiting-run pass, and the pass and the sweep try again later. A fire-and-forget report on the invoke path has no follow-up, so it returns the error. Its step and the parent’s wait are already committed, so the pass still admits the child and resumes the parent. A cancel from the buffer that fails is only logged, and the log read that follows tries the same event if its row is stored.
Follow-ups are not capped in number, only the work in them that holds a connection. An admission’s follow-up lives through its audit waits, up to 5 s per row, and then up to 5 s for a slot. So the number alive at once scales with the rate of arrivals times that time. Each costs a goroutine, not a connection, while it waits.
These limits also apply when nothing is broken, because workflow and capacity audit is best effort (ADR 0083). See Audit rows can drop under load.
Parent wakes and an accepted invoke’s admission effects hold a slot while they finish the invoke step and resume the parent, or replay, dispatch and resume, on every path, and release it before their audit row. A waiting invoke’s child on the invoke path takes no slot: its replay and dispatch run on the dispatch’s own goroutine, which is not capped.
- Neither the wait for a slot nor the work under it has a deadline. Giving up on the wait would leave the parent for the waiting-run pass, a deadline on the step write would also cut off its blob upload, and a resume or a child’s dispatch cut off by a deadline only leaves its work for the same pass. So wakes stay unlimited in number while a table is stuck.
- A resume’s admission can fail in two ways. An error a retry can change, such as a lock timeout, a freeze or a store failure, changes nothing: the parent stays
waiting, no failure is recorded, and the waiting-run pass tries again. A refusal no retry can change fails the parent, and itserrorsays why: a push function has no endpoint, the mode is unsupported, a pull run has no function version, a concurrency key cannot resolve, or an invoke’s pinned target does not match its run. That failure commits in one transaction with any timer’s result, only if the run has not changed since the wake read it. It sends the usual frames, therun.status_changedrow and the wake of any parent above it. During a freeze it stays pending and lands after the thaw. An error not on this list counts as retryable, because a failure cannot be taken back. So a permanent refusal Ironflow does not yet classify leaves the runwaiting, and the pass logs it on every attempt. - Each wait for a lock inside the step write and the resume’s admission, which writes the run and queues its next segment in one transaction, has a 1 s limit on PostgreSQL, so a stuck row cannot keep the slot from other parents, even on a pool under 8, where there is only one slot. The wake gives up, and each one on that row can still delay other wakes by up to 1 s. The waiting-run pass finishes it after the lock clears. The limit also covers a table lock those writes wait on, so a plain
CREATE INDEXonstepssends the wakes that arrive during it to the pass. The reads before those writes and the blob upload have no limit, so they still hold the slot. A child’s dispatch off a report also has none. A child’s dispatch from the pass has the limit described below. - An admission’s effects wait up to 5 s for a slot, and a report or a pass runs one set for each child it admits. Past that wait they stop, and nothing is lost: the admission is committed, and the waiting-run pass replays, dispatches and resumes from it, when its cursor next reaches the parent. The rows go before the wait and do not depend on a slot. Under the slot the work has no deadline, for the same reasons as a wake, and its resume gives up on a locked row like a wake’s. So a stuck table can outlast the shutdown drain, and a process that exits with such work running recovers it on the next start.
- The timeout sweep leaves out an invoke step whose child has ended, for up to 24 hours past its deadline, and the waiting-run pass does not time one out either, so a late wake does not turn a finished child into a failed parent. Past that the step times out, which is how a parent ends when its wake can never land, such as when the child’s output blob is gone. At the default 1 s scheduler interval the waiting-run pass covers about 860,000 waiting runs in 24 hours, so a list longer than that can still fail a parent whose child finished.
- The waiting-run pass runs the wakes it finds off its loop, and skips one when no slot is free. A skipped run comes back when the pass’s cursor reaches it again, which on a long list of waiting runs can take minutes. When a run’s invoke step has already finished, the pass resumes the run on its own loop and takes no slot, and it gives up on a locked run row after 1 s. So do its admission of a thawed invoke, its timeouts and its own sleep wakeups, and those limits also cover the instance authority row, which an open freeze or thaw holds. The pass has its own goroutine, so a stall on the sleep sweep does not stop it. A child it dispatches gives up on a lock after 1 s too, on the
dispatch_bucketsrow, theconcurrency_lanesrow, the child’s run row or the instance authority row. Neither run fails: the child stayswaiting_for_capacity, the parent stayswaiting, and a later pass admits the child after the row clears. Each one on a stuck row can delay the rest of the page by up to 1 s per row the dispatch waits on. A cancel that the replay before that dispatch writes has no limit, so a stuck child run row can stall the pass while it writes one. So does thecancel_oncleanup after a dispatch fails a child whose key cannot resolve, on a stuckcancel_on_specsrow. A recovered child’srun.createdrow goes first, off the loop, and the pass waits up to 1 s for it before the replay and the dispatch. So a stuckaudit_eventstable delays each such child by up to 1 s, and its row lands late with the run’s creation time. - A sleep wakeup gives up on a lock after 1 s, on the step row, the run row, the
dispatch_bucketsrow, theconcurrency_lanesrow or the instance authority row, and the sweep moves on to the next sleep. A resume on the pass gives up on the same rows and leaves the runwaitingfor a later pass. Each one on a stuck row can delay the sleeps behind it by up to 1 s. The wakeup finishes the step and queues the run in one transaction, so giving up changes nothing. A sleep the sweep claimed, which on PostgreSQL is every sleep it finds, keeps its claim until the stale-claim recovery releases it, 2 to 3 minutes after the claim by default, and a later sweep wakes it. The recovery skips a locked row and retries it on its next tick, so one stuck row does not hold the recovery of other runs. During a freeze the recovery changes nothing, and claimed sleeps stay pending until the thaw. - A freeze, thaw, reconcile or attest holds the instance authority row, and on PostgreSQL it waits at most 500 ms for each lock it needs under that row, such as
audit_eventsfor its audit insert. Past that it rolls back, changes nothing, frees the row and returnsUnavailable, so a stuck table delays the sleep sweep’s claim, which has no limit, by about 500 ms per attempt. Send the request again once the table is free. A transaction stuck in its commit, such as on storage, still holds the row and stops the claim, and with it the sweep’s wakeups, until it ends. The deferred-retry replay has its own goroutine and no limit. A stall in it, on that row or on any row its dispatch waits on, such as adispatch_bucketsrow, holds up other deferred retries and does not stop the wakeups. Neither stall reaches the waiting-run pass. - The correlation write of a replayed reply gives up on a lock after 1 s. The run still resumes, and the
waiting_correlationsrow stays unsatisfied unless the reply is delivered again. A later event for that row does nothing, because its step has finished.
A frame’s step read, a recovery failure’s cancel_on cleanup and an invoke’s flow-map edge share the third set of slots. Each keeps to its 5 s, wait included. Past it, the frame goes without its step summaries, the cleanup is left for a later match, and the edge is dropped until the next invoke of that pair draws it. An edge is written off its caller, so an uncommitted insert of the same edge holds neither a report nor an admission. An ordinary child, on a store without native pending invocations, still writes its edge on the invoke path.
These limits leave room for other work, up to three quarters of the pool between them at the default size. They do not make a stuck table harmless. Requests and normal completions that read it are not capped, and enough of them can still take the pool. Each set has at least one slot, so on a pool of 3 or fewer a stall in every set can take every connection.
Audit rows can drop under load
Section titled “Audit rows can drop under load”Workflow and capacity audit is best effort, also on a healthy database. Do not use these rows as a complete record of a run. The run row and its steps are the durable record, and the run’s outcome does not depend on its audit rows.
| Load on a healthy database | Result |
|---|---|
| The store commits writes as fast as they arrive. | No rows are lost. |
| More than 1024 writes are running or waiting, for example in a burst. | The writes past 1024 are lost at once, however fast the store is. |
| Writes wait for a slot for more than 5 s. | The late writes are lost. |
A slot wait can reach 5 s before the backlog reaches 1024. On PostgreSQL this happens when each commit takes longer than about 5 s x slots / 1024, which is about 29 ms at the default pool of 25 (6 slots). SQLite commits one write at a time, so its limit is about 5 s / 1024, or 5 ms.
What to alert on. Alert on ironflow_audit_write_failures_total, which needs IRONFLOW_METRICS_ENABLED=true. A label set has no series until its first failure, and increase alone misses that first burst, so also match new series:
sum by (event_type) (increase(ironflow_audit_write_failures_total[5m])) > 0or sum by (event_type) (ironflow_audit_write_failures_total unless ironflow_audit_write_failures_total offset 5m)Each row that fails in the recorder counts here, as event_type="recording_lookup" when it failed at its recording lookup. A hot patch whose run read fails after it commits counts as event_type="step.patched", also when the function records no steps, because that read is what names the function. A write that times out as it commits can be stored and also counted, so the count can be a little high. Without Prometheus, read delivery_failure_count from GET /api/v1/audit/settings, which counts since the process started. Do not alert on the audit warning from /ready: the next successful write clears it, so it can show healthy while rows are lost.
Deleting a function does not count as losing a row. Recording is a setting on the function, so a node that has not read that setting before the delete treats the function as not recording, and the run’s later rows are neither written nor counted. Recording is opt-in, so counting them would fire this alert on a routine delete of a function that records nothing. The node that took the delete stops at once. Another node that read the setting before the delete can keep writing rows for about 30 s, until its next refresh of the setting. A store fault during a refresh keeps the last setting and does not count here, because rows keep following that setting.
What the cause is. The counter does not show why a row was lost. The error field of the audit: warning log does:
error contains | Cause | What to do |
|---|---|---|
too many audit writes pending | The backlog was full. | Record less, or make commits faster. |
waiting for an audit slot and a deadline | No slot came free in 5 s. | On PostgreSQL, give the pool more connections, because a quarter of them are slots. SQLite always has 4. Then check commit latency. |
audit recorder stopped or context canceled | The engine was stopping, or the request that wrote the row was cancelled. | Nothing, if it matches a shutdown or a client that disconnected. |
anything else, including a bare context deadline exceeded | The store refused the read or write, or the write took its slot and did not finish in 5 s. | Check the database. |
To record less, use a smaller recording profile such as run_lifecycle. Time travel needs the all profile, so this turns it off for that function.
Outcomes nobody announces
Section titled “Outcomes nobody announces”A child run that step.invoke created but never started can be cancelled before it runs, because its link to the parent failed (invoke-link-failed), another result won (invoke-result-won) or its parent ended first (parent-ended). When the invoke path writes that cancel, it sends no RESULTS frame, no run event and no audit row. No parent waits on such a run: no invoke step links it, or its parent has already ended. When the orphan sweep settles the same kind of run, it publishes an updated run event, not cancelled.
Two recovery paths announce the same way as a user cancel and a normal failure:
- A run cancelled by a
cancel_onmatch gets the same follow-up as a user cancel: the RESULTS frame, thecancelledrun event, arun.cancelledaudit row whosecauseiscancel-on-event, and the parent wake. - A run that fails with
EXECUTION_RECOVERY_EXHAUSTEDon its 11th consecutive lease recovery, one past the default ceiling of 10, gets the RESULTS frame, thefailedrun event, arun.status_changedaudit row when the function records run lifecycle, and the parent wake.
Both announce the row they committed, not a later read of it. An operator can resume a failed run, and a resume on another node straight after the commit does not change what this failure announces. The audit row is written last, so a stuck audit write delays neither the frame nor the wake.
A resumed run that finishes inside the 2 minute window sends no second RESULTS frame, because JetStream drops it as a duplicate of the failure’s frame. A waiting caller still gets the outcome from the stored run.
What to rely on
Section titled “What to rely on”- Read the run (
ironflow run get,GetRun) when you need to know how a run ended. The row is final once it is terminal. - Treat run and step events as signals to refetch, not as a record. They can be missing, and they can repeat.
TriggerSync,InvokeFunctionSyncandstep.invokealready read the stored outcome when a notification is lost, so they need no extra handling.
Tests that pin this contract
Section titled “Tests that pin this contract”The engine tests in internal/engine/post_commit_outcome_test.go run on SQLite and PostgreSQL:
TestPostCommit_CrashBeforeParentWakeRecovers: a child outcome committed with nothing after it wakes its parent once, for completed, failed and cancelled children.TestPostCommit_RetriedCompletionReportIsIgnored: a repeated completion report changes neither the outcome nor the parent, and JetStream keeps one RESULTS frame.TestPostCommit_CompletionDuringShutdownFinishes: a completion with a cancelled caller context still commits, publishes and wakes the parent.TestPostCommit_FailedPublishStillWakesParent: a failed RESULTS publish leaves the outcome committed and the parent woken.TestPostCommit_GRPCReportAfterLeaseRecoveryIsDiscarded: a streaming worker’s completion or failure after its lease was recovered is dropped.TestPostCommit_GRPCReportAfterRedeliveryIsDiscarded: an old completion or failure that reaches the engine after the run’s next execution was delivered is dropped, and the next execution’s report still commits.
The tests in internal/engine/post_commit_announce_test.go also run on both:
TestPostCommit_CancelIsAnnouncedOnEveryPath: a user cancel and acancel_onmatch each send one RESULTS frame, acancelledrun event and arun.cancelledaudit row, and wake the parent once.TestPostCommit_RecoveryExhaustedRunIsAnnounced: a run the capacity scanner fails withEXECUTION_RECOVERY_EXHAUSTEDsends one RESULTS frame, afailedrun event in its own environment and arun.status_changedaudit row.
The tests in internal/engine/post_commit_followup_test.go also run on both:
TestPostCommit_RecoveryExhaustedAnnouncesCommittedFailure: a resume right after the recovery commit does not change the frame or thefailedrun event.TestPostCommit_CancelOnAnnouncesWithoutRereadingTheRun: a read that fails after acancel_oncommit does not cost the announcement.TestPostCommit_BlockedAuditDoesNotDelayCancelAnnounce: with therun.cancelledinsert blocked, each cancel path still sends its frame and wakes the parent, and acancel_onmatch does not hold the router.TestPostCommit_BlockedAuditDoesNotStallRecoverySweepandTestPostCommit_BlockedAuditDoesNotBlockScannerStop: a blocked audit insert stops neither the next lease’s recovery norScanner.Stop.TestPostCommit_StuckAuditWriteIsBounded: with every audit insert stuck, the sweep andEngine.Stopboth finish and the frame goes out.TestPostCommit_LeaseAuditRowsOutliveTheSweepContextandTestPostCommit_FollowUpConcurrencyIsCapped: a stopping sweep does not cut off a lease’s audit rows, and a burst holds no more connections than the cap and sends its frames anyway.TestPostCommit_ShutdownDrainsCancelOnAnnounceandTestPostCommit_CancelOnAfterStopBeganIsStillAnnounced: shutdown waits for an announce under way, and an outcome committed after shutdown starts is still announced.
The tests in internal/engine/post_commit_audit_cap_test.go also run on both, except the ones that hold a real lock, which need PostgreSQL:
TestPostCommit_ParentWakeAuditTakesASlotandTestPostCommit_LockedAuditTableLeavesThePoolFree: a parent wake’s audit row takes a slot and its parent does not wait for the row. Withaudit_eventslocked, an unrelated read and the scanner still work.TestPostCommit_AuditBudgetCoversTheWaitForASlotandTestPostCommit_AuditBacklogIsBounded: a write that cannot get a slot in 5 s is dropped and counted, and past the bound one is dropped at once while its frame and wake still go out.TestPostCommit_SteadyAuditLoadLosesNoRows,TestPostCommit_SustainedAuditOverloadCountsEveryLostRowandTestPostCommit_SlowCommitsLoseRowsPastTheBudget: on a healthy store, writers that wait for each write lose nothing. A load past the bound, or commits too slow for the budget, lose rows, and every row is stored or counted.TestPostCommit_StopEndsPendingAuditWrites: no audit write is left waiting or starts onceEngine.Stophas returned.TestPostCommit_ShutdownDrainsFollowUpStartedAfterAdmissionClosesandTestPostCommit_OutcomeCommittedAfterStopIsAnnouncedWithoutAudit: shutdown waits for a follow-up started after dispatch admission closes, and an outcome committed afterEngine.Stopstill gets its frame and wake.TestPostCommit_FollowUpStoreWorkTakesASlotandTestPostCommit_LockedTableLeavesThePoolFreeOfFollowUps: a burst of wakes, frames andcancel_oncleanups keeps to its slots, and withstepsorcancel_on_specslocked an unrelated read and the scanner still work.TestAdmission_PermanentErrorFailsWithReason,TestAdmission_PermanentErrorWakesTheParentsParent,TestAdmission_PermanentErrorLosesToAConcurrentOutcome,TestAdmission_PermanentErrorWaitsOutAFreezeandTestAdmission_RetryableErrorKeepsTheRunWaiting, on both, andTestPostCommit_LockWaitLeavesAdmissionWaiting, on PostgreSQL: a refusal no retry can change fails the parent with its reason and the usual effects, loses to an outcome committed since, and waits out a freeze or a locked row, while any other error leaves the parent waiting with nothing written.TestPendingInvocationChildLockLimitCoversAuthorityandTestPendingInvoke_PassGivesUpOnALockedAuthority, on PostgreSQL: with the instance authority row locked, admitting a thawed invoke and the pass return at their limit, change nothing, and the child is admitted once the lock clears.TestPostCommit_StuckRowDoesNotStallOtherWakes,TestPostCommit_PassGivesUpFailingALockedRun,TestPostCommit_PassGivesUpOnALockedWaitEvent(all three PostgreSQL only) andTestPostCommit_InvokeTimeoutHonoursEndedChild: on a pool of 4, a locked step or run row holds up neither other parents nor the pass, the parent recovers after the row clears, a failure still lands once the row clears, and the timeout sweep leaves out an ended child for its grace and no other step.TestSleepWake_StuckFreezeAuditWriteDoesNotHoldTheSweepandTestInstanceControlStuckAuditWriteReleasesTheAuthorityRow, on PostgreSQL: withaudit_eventslocked, a freeze gives up and changes nothing, the freeze gives up before a wakeup waiting on the authority row would, a due sleep wakes and a deferred retry replays with the table still locked, and the freeze lands once the table is free.TestSleepWake_StuckRowDoesNotStopOtherRuns,TestSleepWake_ClaimedStepOnALockedRowWakesAfterReclaim,TestSleepWake_GivesUpOnALockedAuthority,TestStaleClaimRecovery_SkipsLockedRowsandTestReplayWaitEvent_StuckCorrelationDoesNotStopThePass(all five PostgreSQL only, ininternal/engine/scheduler_stuck_row_test.go): a locked row under a due sleep holds up neither other sleeps, the stale-claim recovery nor the pass, a wakeup gives up on a locked authority row, the sleep wakes after the row clears and the recovery, and a replayed reply reaches its run with its correlation row locked.TestSleepWake_StuckDispatchRowDoesNotStopOtherFunctions,TestReconcileInvokeChild_StuckDispatchRowDoesNotStopThePassandTestReconcileInvokeChild_LockedChildPutsOffItsDispatchFailure(all three PostgreSQL only, in the same file): a locked bucket or lane row, or a dispatching child’s locked run row, holds up neither another function’s sleep nor the pass. No run fails, and each one resumes, is admitted or takes its failure after the row clears.TestScheduler_StalledRetryReplayDoesNotStopSleepWakeups, in the same file: the sleep sweep keeps running while a deferred-retry replay is stuck.TestScheduler_StalledSleepSweepDoesNotStopThePass, in the same file: the pass runs while the sleep sweep is stalled.TestInstanceFrozenStaleClaimRecovery, on both, ininternal/store/instance_reservation_test.go: a recovery during a freeze changes nothing, and the claims come back after the thaw.TestWaitingYieldRecovery_BlockedCreatedRowDoesNotHoldDispatch, on both: a recovered child’srun.createdrow goes before its dispatch, and a stuckaudit_eventstable holds the pass for a bounded time and the dispatch not at all.TestPostCommit_WaitingRunPassSkipsWakeWithoutASlotandTestPostCommit_WaitingRunPassWakeOutlivesItsContext: the waiting-run pass starts no wake without a free slot, and a wake it started survives the loop’s context ending.TestPostCommit_RecordingLookupStopsWithTheRecorder: a recording lookup does not run once the recorder has stopped, and the lost row is counted.
The tests in internal/engine/post_commit_blocked_audit_test.go show what a stuck audit table delays on each path:
TestPostCommit_BlockedAuditDoesNotDelayRunOutcome, on both: a pull or push completion, a pull or push failure, a push transport failure and an invoke failure each return and send their frame with audit writes stuck, and the row lands once the table recovers. Each of those paths wakes its parent at once.TestPostCommit_LockedAuditTableDoesNotDelayRunOutcome, on PostgreSQL: withaudit_eventslocked, a pull completion returns, sends its frame and wakes its parent.TestPostCommit_OutcomeAuditOutlivesTheCaller, on SQLite: a caller that goes away does not cost any of those paths its row.TestPostCommit_BlockedAuditHoldsSweepsOnlyAfterTheFrame, on both: a failed dispatch, the scheduler’s reclaim pass and the orphan sweep send their frame, then wait for their row.TestPostCommit_BlockedStepAuditDelaysTheReport, on both: a stuck step row holds the frame until it clears, on pull and push.TestPostCommit_BlockedAuditDoesNotHoldTheRouter, on both: with every audit write stuck, the router returns from an event that wakes a waiting run and starts another.TestPostCommit_BlockedAuditDoesNotDelayTerminalContinuationandTestPostCommit_BlockedAuditDoesNotHoldBrokerResumeFailure, on both: a continuation that fails its run and a broker resume that cannot dispatch each return with their row stuck, and the row lands once the table recovers.TestPostCommit_BlockedRunCreatedHoldsOnlyItsDispatch: a stuckrun.createdrow holds the run’s dispatch, and not the router or the cron loop, on both, or the invoking parent’s report, on SQLite.TestPostCommit_BlockedAuditDoesNotHoldInvokeReport, on both: with every audit write stuck, astep.invokereport returns, sync or fire-and-forget, and its rows, the child’s dispatch and the parent’s resume follow in order once the table recovers, even with the caller gone.TestRunCreatedRow_*, ininternal/engine/run_created_row_test.go: a direct invoke, a debounced run, a recovered debounced run, a fire-and-forget child and a child cancelled after a lost link each get the row, and a row that lands late reads as the run’s first event.TestPendingInvoke_RunCreatedPrecedesReplayedCancel, on both: a child’srun.createdrow sorts before arun.cancelledrow from the replay buffer, and replay waits for it.TestPendingInvoke_CrashAfterReportIsRecovered, on both: losing everything after a report’s commit costs the rows, and the waiting-run pass admits the child and resumes the parent.TestPostCommit_BlockedAuditDoesNotStallInvokeRecoveryandTestPostCommit_InvokeRecoveryRowOutlivesThePass, on both: a page of children released by a thaw does not hold the waiting-run pass behind a stuck audit table, each child’s dispatch follows its row, and a stopping pass does not cost the row.TestPendingInvoke_ReadFailureAfterCommitKeepsEffects, on both: a step or child read that fails after an admission’s commit does not cost its rows or its replay, on the report or the pass.TestPendingInvoke_RecoveryDispatchReplaysCancelandTestPendingInvoke_RetriedYieldReplaysCancel, on both: the pass or a retried yield cancels a child from the buffer or the event log before dispatching it, with the admission’s rows stuck, after a crash and after a restart.TestOrphanSweep_LinkedChildReplaysBeforeAdmission, on both: the sweep cancels a linked child from the event log rather than admit it, and a failed read leaves the child for a later sweep.TestOrphanSweep_PagesPastChildrenItCannotReplay, on both: more than a page of children whose replay keeps failing are never dispatched and do not hide a healthy orphan behind them, and the sweep comes back to them once replay recovers.TestOrphanSweep_ComesBackToASkippedRunUnderSustainedArrivals, on both: with a full page of new orphans on every tick, whether the sweep settles them or skips them, it still re-admits a child it skipped once the child recovers, even after the first revisit has passed it.TestInvokePath_ChildHonoursCancelAnotherNodeTookandTestInvokePath_ChildWithoutAMatchIsDispatched, on both, with two engines on one database: a child created on the invoke path is cancelled before any dispatch by a cancel only the other node buffered, and its parent resumes. An event that does not match leaves it dispatched.TestInvokePath_RunCreatedPrecedesReplayedCancel, on both: such a child’srun.createdrow sorts before arun.cancelledrow from the replay.TestInvokePath_StoppingEngineHonoursCancel, on both: a child queued for the next boot during a shutdown gets the same replay, and is cancelled instead of queued.TestInvokePath_FailedReplayLeavesTheChildToRecovery, on both: a failed replay read does not dispatch the child, and the waiting-run pass admits it and resumes the parent.TestPendingInvoke_ReplayReadsTheEventClock, on PostgreSQL with a database clock ahead of the process: the replay reads a cancel stamped after the process’s time.TestPostCommit_InvokeFollowUpOutlivesStop, on both: a follow-up that outlastsEngine.Stopfinishes with the store open or closed, and its work lands if the store is open.TestPendingInvoke_NoSlotLeavesAdmissionToThePass, on both: with no slot free the rows land, the child is not dispatched and the parent not resumed, and the next pass does both.TestPostCommit_LockedInvokeEdgeLeavesThePoolFree, on PostgreSQL: withfunction_invokeslocked, a report and a thaw’s page of children return, keep to their slots, leave the pool free, dispatch and resume, and each edge write ends at its deadline.
TestResultPublishFailurePolicyAtEveryDoor covers the failed-publish policy at every code path that ends a run, and the fencing tests in yield_fenced_outcome_test.go and worker_fence_test.go cover stale push and streaming responses.