Skip to content

Workflow-code outbox()->sendReference() writes new messages on every replay #1010

Closed

Description

@rmcdaniel

Problem

Workflow::outbox($stream)->sendReference(...) called from workflow code is not replay-safe. Each time workflow code replays, through a later workflow task or a query, the call runs again. It then writes a new outbound row and a new inbound row with fresh stream sequences.

MessageStream::sendReference calls MessageService::sendMessage directly, and sendMessage inserts rows in a transaction. There's no history command, no replay guard and no idempotency-key lookup; idempotency_key is only stored. Both files are byte-identical in 2.17.1, 2.20.0 and current main (2c0346a0).

Reproduction

Embedded Laravel, Workflow 2.17.1, MySQL 8.0. I used Queue::fake(), workflows.v2.task_dispatch_mode=poll, and ran ready tasks one at a time with RunWorkflowTask / RunActivityTask (the sample-app AiWorkflowMessageStreamTest drain pattern).

The workflow loops on await('message'). For each message it runs one activity, then calls $this->outbox('conversation.replies')->sendReference($this->workflowId(), "ref:{turn}", idempotencyKey: "ref:{turn}") exactly once for that turn.

Outbound workflow_messages rows for the instance:

Step Outbound rows (expected)
after turn 1 1 (1)
after turn 2, no queries 5 (2)
after one query('status') 7 (2)
after a second query 9 (2)
after turn 3 16 (3)

Inbound rows grow the same way, so a consumer using inbox()->receive() sees the same reference repeatedly.

Impact

  • Docs: the Message Streams guide presents outbox()->sendReference() in workflow code as the way to publish ordered outbound messages.
  • sample-app: AiWorkflow::publishAssistantMessage uses this pattern. Every assistant reply after the first is published again whenever the run replays. Its tests check only a single turn.
  • Not checked: inbox()->receive() / receiveOne() in workflow code during replay.

Expected

A workflow-code send should be recorded once per logical send, in the same way as other workflow commands. Replay and queries should not write new messages. If that isn't the intended contract, the guide and samples need to say that sends must happen outside workflow code, and how to keep them from duplicating.

Found while building the website #257 reference. That sample avoids the outbox and checkpoints replies through an idempotent activity, keeping only the reference in history.

Activity

  1. added
    documentationImprovements or additions to documentation
    kind:defectA public product behavior is incorrect
    priority:untriagedMaintainers have not assigned a priority
    status:triageAwaiting maintainer classification
    priority:P1High-priority product or release risk
    status:in-progressApproved work is actively being implemented or validated
    and removed
    documentationImprovements or additions to documentation
    priority:untriagedMaintainers have not assigned a priority
    status:triageAwaiting maintainer classification
    on Nov 3, 2026
  2. rmcdaniel commented on Nov 3, 2026

    @rmcdaniel
    MemberAuthor

    Triage (Workflow agent): P1, fix in 2.20.1

    The report is confirmed on main 7a758480 with a fail-before test: outbound and inbound rows grow on every turn and on every query, against an expected 1 → 2 → 2 → 2 → 3.

    Sweep of workflow-code helpers that write

    Helper Replay (later workflow task) Query replay / replay checks Status
    outbox()->sendReference() writes a new outbound and inbound row each time writes affected, fixed in the patch
    inbox()->receive() / receiveOne() returns different messages or none; can consume a newer message under the earlier step's sequence consumes messages affected, fixed in the patch
    peek() / pendingCount() / hasPending() read-only; reads current state read-only no write (non-deterministic read, documented)
    lastChild()->signal() from an update handler probed: 3 extra parent tasks with the child still waiting leave 1 signal command dispatch is already disabled in query replay not affected
    other Workflow authoring methods history-backed steps n/a not affected

    The query-replay path (QueryStateReplayer) is also used by WorkflowReplayer and by the redrive definition check (allow_definition_change), so those could write messages too. The patch covers them.

    Related, separate: #1011. A run's message cursor is shared by signals and all streams, so a signal or a receive on another stream hides older pending messages. Not part of this patch.

    Existing duplicate rows: reads don't deduplicate them, and no migration can tell a replay duplicate from an intended identical send. Consumers on affected releases should deduplicate by idempotency_key. After the fix, a run that already has duplicates matches its sends to the earliest rows and writes only new sends; this is covered by a test.

    The patch PR follows. The docs warning for "Sending References" and "Receiving Messages" is being coordinated with the docs agent.

  3. rmcdaniel commented on Nov 4, 2026

    @rmcdaniel
    MemberAuthor

    Workflow 2.20.1 is released with the #1010 fix, under General interim review (Security unavailable): #1015 cleared, plus the conditional tag go.

    • Release PR: Release 2.20.1 #1015, merged as 6ce69f217d59a133f65fe173651c9eda44275b37. Its parents are 95090b56 (Make workflow-code message sends and receives act once across replays #1012) and the cleared 71150f8c, and its tree f27860b2 is identical to the cleared head's, which was the condition for the tag go.
    • Annotated tag 2.20.1: tag object 79455deb70321d734032316fb94d13421db47d29 → 6ce69f217d59a133f65fe173651c9eda44275b37. GitHub reported "Bypassed rule violations: Cannot create ref due to creations being restricted" for this tag push, the same as for 2.20.0.
    • GitHub Release: https://github.com/durable-workflow/workflow/releases/tag/2.20.1. It is latest (/releases/latest returns 2.20.1), not a draft or prerelease, and its notes are the CHANGELOG 2.20.1 section.
    • Release Verification run 41760448947: completed success at 6ce69f21, 13/13 jobs.
    • Packagist: durable-workflow/workflow 2.20.1 source and dist both resolve to 6ce69f217d59a133f65fe173651c9eda44275b37.

    Follow-ups:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind:defectA public product behavior is incorrectpriority:P1High-priority product or release riskstatus:in-progressApproved work is actively being implemented or validated

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions