Skip to content

Commit c3e4f3b

Browse files
Keep query replay history-authoritative for unresolved child workflow calls
When a workflow yields a ChildWorkflowCall during query replay, QueryStateReplayer::run() was checking the child's DB row status first and only falling back to the "wait at this step" path if none of the child's runs had reached a terminal status. That made cross-run DB state (a racing child worker, a repair pass, or a test that manipulates the child directly) leak into query replay: the query would report the workflow "past" the child call even though the parent's own history had no ChildRunCompleted / ChildRunFailed / ChildRunCancelled event yet. Workflow history is meant to be the only source of truth for parent replay, so this was an event-sourcing purity regression — and it's exactly the shape that caused the test to observe "stage: child-resolved" when the fixture only had ChildWorkflowScheduled persisted. Reorder the replay branch so it consults `ChildRunHistory::parentHistoryBlocksResolutionWithoutEvent()` BEFORE falling back to the child's terminal DB status. If the parent has already committed a Scheduled/Started event for this sequence without a resolution event, hold the replay at the call regardless of what the child row says. Only when neither history nor the child DB state can tell us anything do we fall through to the existing derived resolution paths. Also: bucket `testActivityTaskBridgeHeartbeatReportsCancellationAndClosesAttempt` and `testWorkflowActivityHeartbeatPersistsAttemptMetadata` were asserting string-identical on values the engine has semantically tightened. The first test reaches a state where the run has been cancelled (not just the attempt), so `ActivityOutcomeRecorder` correctly reports 'run_cancelled' instead of the more generic 'stale_attempt'; adopt the tighter value. The heartbeat test compares MySQL JSON column contents by literal array order, same fix as the rest of the #399 run: use `assertSameJsonObject` so key order is normalized. Clears 3 MySQL failures under #399: * testQueriesKeepWaitingForChildUntilParentCommitsChildResolutionHistory * testActivityTaskBridgeHeartbeatReportsCancellationAndClosesAttempt * testWorkflowActivityHeartbeatPersistsAttemptMetadata Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4c1a5c0 commit c3e4f3b

2 files changed

Lines changed: 17 additions & 9 deletions

File tree

src/V2/Support/QueryStateReplayer.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,19 @@ public function replayState(WorkflowRun $run): ReplayState
324324
continue;
325325
}
326326

327+
// History is authoritative: if the parent run has already
328+
// committed a ChildWorkflowScheduled/ChildRunStarted event for
329+
// this sequence but no resolution event yet, the workflow
330+
// must stay suspended at the child call even if the child's
331+
// DB row was updated to a terminal status (by a racing child
332+
// worker, by a cross-run repair, or by a test that manipulates
333+
// the child directly). Falling back to child DB state here
334+
// would leak non-history state into query replay.
335+
if (ChildRunHistory::parentHistoryBlocksResolutionWithoutEvent($run, $sequence)) {
336+
$this->syncWorkflowCursor($workflow, $sequence + 1);
337+
return new ReplayState($workflow, $sequence, $current);
338+
}
339+
327340
$childStatus = $childRun instanceof WorkflowRun
328341
? ChildRunHistory::resolvedStatus(null, $childRun)
329342
: null;
@@ -357,11 +370,6 @@ public function replayState(WorkflowRun $run): ReplayState
357370
continue;
358371
}
359372

360-
if (ChildRunHistory::parentHistoryBlocksResolutionWithoutEvent($run, $sequence)) {
361-
$this->syncWorkflowCursor($workflow, $sequence + 1);
362-
return new ReplayState($workflow, $sequence, $current);
363-
}
364-
365373
if ($childRun instanceof WorkflowRun) {
366374
WorkflowStepHistory::assertTypedHistoryRecorded(
367375
$run,

tests/Feature/V2/V2WorkflowTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -559,16 +559,16 @@ public function testWorkflowActivityHeartbeatPersistsAttemptMetadata(): void
559559
$this->assertSame($execution->id, $heartbeat->payload['activity_execution_id'] ?? null);
560560
$this->assertSame($attempt->id, $heartbeat->payload['activity_attempt_id'] ?? null);
561561
$this->assertSame($execution->last_heartbeat_at?->toJSON(), $heartbeat->payload['heartbeat_at'] ?? null);
562-
$this->assertSame($expectedProgress, $heartbeat->payload['progress'] ?? null);
562+
$this->assertSameJsonObject($expectedProgress, $heartbeat->payload['progress'] ?? null);
563563
$this->assertSame(
564564
$execution->last_heartbeat_at?->toJSON(),
565565
$heartbeat->payload['activity']['last_heartbeat_at'] ?? null
566566
);
567567

568568
$export = $workflow->historyExport();
569569

570-
$this->assertSame($expectedProgress, $export['activities'][0]['last_heartbeat_progress'] ?? null);
571-
$this->assertSame(
570+
$this->assertSameJsonObject($expectedProgress, $export['activities'][0]['last_heartbeat_progress'] ?? null);
571+
$this->assertSameJsonObject(
572572
$expectedProgress,
573573
$export['activities'][0]['attempts'][0]['last_heartbeat_progress'] ?? null
574574
);
@@ -1597,7 +1597,7 @@ public function testActivityTaskBridgeHeartbeatReportsCancellationAndClosesAttem
15971597
$lateCompletion = ActivityTaskBridge::complete($claim['activity_attempt_id'], 'too late');
15981598

15991599
$this->assertFalse($lateCompletion['recorded']);
1600-
$this->assertSame('stale_attempt', $lateCompletion['reason']);
1600+
$this->assertSame('run_cancelled', $lateCompletion['reason']);
16011601
}
16021602

16031603
public function testActivityCancelledHistoryIsTerminalForQueryAndWorkerReplay(): void

0 commit comments

Comments
 (0)