Skip to content

Make message delivery push-only#180

Merged
stidsborg merged 21 commits into
mainfrom
restart-not-running-flows-on-push
Jul 4, 2026
Merged

Make message delivery push-only#180
stidsborg merged 21 commits into
mainfrom
restart-not-running-flows-on-push

Conversation

@stidsborg

@stidsborg stidsborg commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Makes message delivery fully push-based and moves flow wake-up into the MessageWatchdog's responsibility.

Architecture

  • QueueManager no longer reads from the message store: messages arrive exclusively via push — the MessageWatchdog poll (running at MessagesPullFrequency) and the restart in-hand hand-over.
  • FlowsManager.Push delivers to live flows and claims + restarts parked ones with the pushed messages in hand — the message row itself is the durable wake-up signal.
  • AppendMessages no longer interrupts target flows; the InterruptedWatchdog is removed (the public Interrupt API and the suspend-time guard remain).
  • MessageWatchdog.Notify: appends complete a wake signal that cuts the inter-poll sleep short, so locally appended messages are delivered immediately. Notify only completes a signal — the fetch-and-push always runs on the watchdog's own loop.
  • The ReplicaWatchdog hands over crashed replicas' message rows, which push-only requires.

Root-cause fixes along the way

  • MariaDB batch restart claims were imprecise: UPDATE..WHERE owner IS NULL followed by SELECT returned rows claimed by an earlier same-replica call as newly claimed, letting two watchdogs restart the same flow concurrently (the CI-only concurrent modification flakiness). Now a locking SELECT..FOR UPDATE + UPDATE in one transaction, mirroring PostgreSQL's RETURNING/SqlServer's OUTPUT precision. Regression-tested against all stores.
  • Batch claims are status-guarded (Postponed/Suspended only) so late messages cannot resurrect completed flows.
  • EffectContext is reset at the start of every invocation: the invocation Task.Run captures the scheduler's execution context, and inheriting a mutable ambient context from the spawning chain shifted implicit effect ids across incarnations, breaking replay determinism.
  • Marked-pushed positions are never silently dropped: pushes hitting disposed/suspended/absent flows, unclaimable flows, poisoned (deserialization-failure) queue managers and unregistered types all reopen their positions — a stranded position loses the flow's wake-up.
  • Delivery-order guarantees: staged messages are position-sorted; idempotency-key dedup is order-sensitive and batches stay position-ordered.

Every commit was verified green on all four CI store jobs before the next.

🤖 Generated with Claude Code

@stidsborg stidsborg closed this Jun 28, 2026
@stidsborg
stidsborg force-pushed the restart-not-running-flows-on-push branch from bde3271 to e18b535 Compare June 28, 2026 10:32
stidsborg added 17 commits July 2, 2026 08:03
RestartExecutions claimed via UPDATE..WHERE owner IS NULL and then
SELECTed all requested rows, filtering by owner = replica. A flow
claimed by an earlier call from the same replica was therefore returned
to a concurrent caller as if it had just been claimed, letting two
claimers (e.g. two watchdogs) restart the same flow concurrently -
surfacing as UnexpectedStateException: concurrent modification.

Run a locking SELECT..FOR UPDATE and the claiming UPDATE inside one
transaction so the caller receives exactly the rows its own call
claimed, mirroring the already-precise UPDATE..RETURNING (PostgreSQL)
and UPDATE..OUTPUT (SqlServer) implementations.
The batch RestartExecutions/RestartExecutionsWithoutMessages claimed any
ownerless flow, so a caller targeting a flow that had meanwhile
completed would resurrect it - e.g. a message arriving after its target
succeeded, once message-driven restarts land. Guard the claim with
status IN (Postponed, Suspended) in all stores. The singular
RestartExecution (manual restart) intentionally keeps claiming
completed flows.
PrepareForReInvocation received the messages loaded by the restart
claim but discarded them, so a restarted flow had to re-fetch its own
messages from the store. Push them straight into the queue manager's
delivery pipeline instead. Push now initializes the queue manager
first so the idempotency-key state is loaded before processing.

Initialize additionally seeds the fetched-positions set from the
persisted delivered positions: the in-hand messages are loaded at
claim time - before Initialize clears the delivered positions from
the store - so without the seed a message the previous incarnation
already delivered (but had not yet cleared) would be delivered again.
Extract the MessageWatchdog's fetch-and-push cycle into PushOnce and
invoke it when a queue manager initializes through CreateQueueClient,
so a freshly started/resumed flow receives its pending messages
immediately instead of waiting for the next watchdog poll.

The pending-push is skipped when initialization is triggered by an
incoming Push: the pusher already holds an in-flight batch and a
nested fetch would stage newer messages ahead of it. Staged messages
are additionally kept position-sorted so delivery order stays append
order even when concurrent pushers stage batches out of order.
GetMessagesForReplica only returns messages assigned to the fetching
replica, so messages published by a crashed replica to ownerless flows
were never fetched again once the replica died. The ReplicaWatchdog now
reassigns a crashed replica's message rows to itself alongside
rescheduling its crashed functions - before deleting the crashed
replica from the replica store, so an interrupted handover is retried
on a later iteration.
Three tests constructed their own QueueManager inside the flow, which
exercised the store-pull directly and duplicated runtime wiring:
- QueueManagerFailsOnMessageDeserializationError now registers the
  exception-throwing serializer on the registry and uses
  workflow.Message
- RegisteredTimeoutIsRemovedWhenPullingMessage now asserts through the
  observable postpone: a delivered message's timeout must not linger
  and shorten a subsequent delay's postpone
- PullEnvelopeReturnsEnvelopeWithReceiverAndSender uses the flow's own
  queue manager via a new internal Workflow.QueueManager accessor

The tests now stay valid when the queue manager's store-pull is
removed and message delivery becomes push-only.
A push whose target flow was suspended, not yet queue-manager-attached,
or absent from the manager's dictionary was silently dropped while its
positions stayed in the clearer's ignore-set. Later fetches then only
returned higher positions, so a later-positioned duplicate consumed the
idempotency key before the original message - observed on CI as
MultipleIterationsWithDuplicateIdempotencyKeysProcessCorrectly
delivering the second-copy values.

The queue manager now attaches to its flow state at construction, and
undeliverable pushes reopen their positions so they are re-fetched
instead of stranded.
The invocation body runs on a Task that captures the scheduler's
execution context. When a flow is restarted from a watchdog chain that
has itself touched the ambient EffectContext, all invocations spawned
from that chain inherit - and mutate - the same context instance, so
implicit effect ids shift between incarnations and replay diverges:
a restarted flow re-subscribes under a different effect id, filters on
the wrong message and suspends, restarting endlessly.

Give every invocation a fresh context at start so implicit ids are
deterministic regardless of the scheduling context.
MessagesPullFrequency (default 250ms) lost its consumer when the
per-flow fetch loop was removed. The MessageWatchdog is the message-
delivery loop, so it should run at that frequency - the slower
watchdog check frequency made every push-restarted message exchange
poll-bound.
Messages pushed to a flow that is not live are now delivered by
claiming and restarting the flow (RestartExecutionsWithoutMessages)
with the pushed messages handed to the new invocation - the message
itself becomes the wake-up signal instead of relying on the interrupt
flag. A flow-state entry whose flow has suspended counts as not live:
a suspended flow's parked invocation never reaches RemoveFlow by
design, and treating the lingering entry as live swallowed the wake-up.

Claim failures distinguish retryable targets (reopened for the next
poll) from completed/deleted flows, whose positions stay ignored so
their messages are not refetched forever. A failed claim call reopens
everything.

Positions that reach a dying queue manager are reopened rather than
dropped: pushes hitting a disposed instance, staged-but-undelivered
messages at dispose, and messages for unregistered flow types. A
stranded position otherwise loses the flow's wake-up once the message
watchdog has marked it as pushed.

The tests' thread pool is pre-sized since every test's registry now
runs its message poll at 250ms, which starves the default pool growth
on small CI runners.
Delete FetchAndNotify and the queue manager's message-store dependency.
Messages now arrive exclusively via Push - the MessageWatchdog poll,
the immediate fetch at initialization and the restart hand-over - so
Subscribe, Interrupt and FetchMessagesOnce only re-evaluate the already
staged messages against the current subscriptions.
Message appends no longer set the interrupted flag: the message row
itself is the durable wake-up signal - the MessageWatchdog fetches it
and either pushes to the live flow or claims and restarts the parked
one with the message in hand. The public Interrupt API and the
suspend-time interrupted-guard remain.

A claim failure for a flow that does not exist (yet) now reopens the
positions instead of stranding them: messages may legally precede
their flow's creation, and the delivery must be retried once the flow
is scheduled.
Message delivery to live flows is handled by the MessageWatchdog's
push, and parked flows are woken by push-restarts - the interrupt
polling loop no longer had a purpose. Remove it together with its
supporting plumbing: FlowsManagers/FlowsManager Interrupt and
FilterOwned, FlowExecutionState.Interrupt, QueueManager.Interrupt and
the GetInterruptedFunctions/ResetInterrupted store operations. The
public Interrupt API and the suspend-time interrupted-guard remain.
Message-driven flow wake-up is push-restart based; the interrupt-only
scheduling helper has no callers left.
The tests rely on workflow.Delay suspending the flow so the captured
work is re-executed on restart. A 10ms delay can fully elapse before
the suspension check on a loaded machine (an effect-persist roundtrip
sits in between), letting the capture complete inline on the first
attempt. Use 500ms so the suspension reliably happens.
The init-time PushOnce ran on the initializing flow's own async branch,
which made restart chains executable from inside another flow's
execution context - the trigger for the EffectContext id-shift bug -
and coupled queue manager construction to the MessageWatchdog. Message
delivery is now driven solely by the watchdog poll and the restart
in-hand hand-over, simplifying the wiring (no push delegate threaded
through FunctionsRegistry/InvocationHelper/QueueManager).

The trade-off is latency: a flow awaiting an already-present message
suspends once and is restarted by the next poll instead of receiving it
inline. Tests encoding the stronger inline guarantee are adjusted:
initial-state flows are awaited tolerantly via Schedule+Completion,
AwaitMessageAfterAppendShouldNotCauseSuspension is removed, and
WorkflowMessageIsIdempotentAcrossRestarts counts deliveries instead of
delivery-timing-dependent invocations.
Add MessageWatchdog.Notify: appending a message completes a wake signal
that cuts the watchdog's inter-poll sleep short, so locally appended
messages are delivered immediately instead of waiting out the poll
interval. The signal is re-armed before each fetch, so a notify arriving
mid-push is never lost - it simply makes the next wait return at once.

Notify only completes the signal: the fetch-and-push always executes on
the watchdog's own loop, so - unlike the removed initialization-time
push - no flow execution context is ever captured by another flow's
restart. Wired into MessageWriter/MessageWriters appends and the
child-to-parent completion message; reopened positions deliberately do
not notify, as a persistently unclaimable flow would otherwise spin the
poll loop.

The faster delivery exposed a stranding hazard: a push whose message
fails deserialization poisons the queue manager without staging the
positions, and when that races the flow's suspension the message stayed
marked forever and the flow never failed. A poisoned queue manager now
reopens the batch's unstaged positions so a restarted incarnation
receives the message and surfaces the failure.
@stidsborg stidsborg reopened this Jul 2, 2026
@stidsborg stidsborg changed the title Restart not-running flows from FlowsManagers.Push and deliver in-hand messages Make message delivery push-only Jul 2, 2026
@stidsborg
stidsborg merged commit cb75b78 into main Jul 4, 2026
8 checks passed
@stidsborg
stidsborg deleted the restart-not-running-flows-on-push branch July 4, 2026 07:41
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