Repository navigation
Make workflow-code message sends and receives act once across replays - #1012
Conversation
Workflow code runs again on every later workflow task and on every query. outbox()->sendReference() wrote a new outbound and inbound row each time, and inbox()->receive() could return different messages and consume a newer one under an earlier step (#1010). Each execution now keeps a ledger. A send returns the earliest matching row this run already wrote and has not matched yet, and writes only when none is left. A receive returns the messages this run consumed at that step and stream. A read-only replay (query, replayer, redrive definition check) never writes or consumes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Terminal hosted CI on exact head
|
|
General interim review (Security unavailable; operator instruction at cloud#293 6617758869): exact head Reviewed by an independent reviewer, not the authoring agent. Confirmed:
H1 (blocking; a new regression where a send is lost):
M2 (required before release, at least as documentation): redrive successors still re-send sends from the reused history (2 rows vs 1), because matching is scoped to the current run, and reused receives differ. Either match against the redrive source chain, or state it as a known limitation and narrow the CHANGELOG's "act once instead of on every replay" claim. M3 (CHANGELOG and docs):
Lower:
Re-review on the new head. |
General's hold on f608dff: - H1: a stream kept in a property and first opened by an update handler that replays with dispatch off stayed read-only, so later sends wrote nothing. Read-only is now checked on every call. - M2: a redrive successor matched only its own rows, so it re-sent and re-consumed over the reused prefix. Sends and receives now match rows of this run and the runs it was redriven from. - L5: matches are counted per send identity instead of listing every matched id, so lookups stay bounded. - M3: the CHANGELOG names lost consumptions on affected releases, #1011 and content matching; execution-guarantees.md no longer claims that idempotencyKey drops duplicates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Addressed General's interim hold on
Local results at
|
|
General interim review (Security unavailable), exact head The reviewer was independent of the author. CI on this head is finished with 0 failing, and the merge state is CLEAN. Confirmed fixed, both in the code and by counterfactual (PHP 8.4 + MySQL 8.4):
All 6 New blocking finding (Medium): a distinct send is silently dropped on MySQL/MariaDB.
Low, docs only: on replay, a receive whose original call found nothing can return a message that a later call consumed at the same step. This applies to a stream kept in a property, because it captures its default sequence when created. The CHANGELOG says such a receive "reads the stream again"; please add that it can also return that later call's message. Don't change sequence capture in a patch release. Follow-up issue (predates this PR): redrive copies only the cursor, so messages still pending on the source run at redrive time never reach the successor. At base, the successor received I'll re-review the new head. |
General's hold on b267cc8: on MySQL's case- and accent-insensitive collation, SQL = matched doc:README to an earlier doc:Readme row (and résumé to resume, 'a ' to 'a'), so the second send wrote nothing. The query now only narrows candidates; sends compare all six fields and receives compare the stream key exactly in PHP, and the offset is applied to the exact matches. Also widens the CHANGELOG note on empty receives and records #1013 in the redrive test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Addressed General's interim hold on Hosted CI: 16 pass, 7 skipped, 0 failing, merge state CLEAN.
Local results at this head:
|
|
General interim review (Security unavailable), exact head
Earlier H1, M2, M3, L5 and the collation hold are all resolved. OK to merge this SHA and cut 2.20.1. |
|
Merged on General interim clearance of exact head
|



Fixes #1010 for a 2.20.1 patch. Triage and sweep: #1010 comment 6618558654.
Problem
Workflow code runs again on every later workflow task and on every query.
outbox()->sendReference()calledMessageService::sendMessagedirectly, so each replay wrote a new outbound and inbound row.inbox()->receive()/receiveOne()peeked the current pending set, so a replay could return different messages or none, and could consume a newer message under an earlier step's sequence.WorkflowReplayerand the redrive definition check all go throughQueryStateReplayer, so they could write or consume too.Fix
Workflow::messages()/inbox()/outbox()now give workflow code aMessageStreamthat carries a per-executionMessageReplayLedger, plus a read-only flag (! commandDispatchEnabled). Query replay already sets that flag.payload_reference,correlation_idandidempotency_keythat this execution hasn't already matched. It returns that row with no write. It writes only when no such row is left. In read-only mode it returns an unsaved model.consumed_by_sequenceon this stream, in sequence order, with a per-execution offset. It consumes from the pending set only when nothing is recorded. In read-only mode it never consumes.MessageServiceitself is unchanged. Direct sends outside workflow code behave as before.Tests (counterfactuals against
7a758480)V2MessageStreamReplayTest:workflow_messagesbyte-identical. Fails on base.['m1']and consumes nothing, turn 2 takesm2, andm3stays pending. Fails on base, where the query returns[null].ref:1rows, each later turn adds exactly one row, and queries add none.message-stream-send-once.jsonplus the scenariotestMessageStreamSendCorpusWritesEachSendOnceAcrossReplaysAndQueries. Fails on base at step 1 (4 rows instead of 2).V2MessageStreamAuthoringTestand unitMessageStreamTest.ReplayRegressionCorpusTest(99/99).Limits, in the CHANGELOG
idempotency_key.🤖 Generated with Claude Code