Skip to content

Commit fd5553b

Browse files
#352: order signal timeout races by history
1 parent f8e9caa commit fd5553b

2 files changed

Lines changed: 123 additions & 9 deletions

File tree

src/V2/Support/WorkflowExecutor.php

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -663,14 +663,28 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
663663
? $this->signalTimeoutFiredEvent($run, $sequence, $current->name)
664664
: null;
665665
$signalWaitId = $this->signalWaitId($run, $sequence, $current) ?? (string) Str::ulid();
666+
$signalCommand = $this->pendingSignalCommand($run, $current);
666667

667-
if (
668+
$timeoutHasWon = (
668669
$current->timeoutSeconds !== null
669670
&& (
670671
$timeoutFiredEvent !== null
671672
|| ($timeoutScheduledEvent === null && $timeoutTimer?->status === TimerStatus::Fired)
672673
)
673-
) {
674+
);
675+
676+
if ($timeoutHasWon && $signalCommand !== null && $timeoutFiredEvent !== null) {
677+
$signalReceivedEvent = $this->signalReceivedEventForCommand($run, $signalCommand);
678+
679+
if (
680+
$signalReceivedEvent !== null
681+
&& $signalReceivedEvent->sequence < $timeoutFiredEvent->sequence
682+
) {
683+
$timeoutHasWon = false;
684+
}
685+
}
686+
687+
if ($timeoutHasWon) {
674688
$this->recordSignalWait($run, $task, $sequence, $current, $signalWaitId);
675689

676690
try {
@@ -689,8 +703,6 @@ public function run(WorkflowRun $run, WorkflowTask $task): ?WorkflowTask
689703
continue;
690704
}
691705

692-
$signalCommand = $this->pendingSignalCommand($run, $current);
693-
694706
if ($signalCommand !== null) {
695707
$signalWaitId = $this->signalWaitIdForCommand($run, $signalCommand, $current->name);
696708

@@ -1855,6 +1867,17 @@ private function pendingSignalCommand(WorkflowRun $run, SignalCall $signalCall):
18551867
return $command;
18561868
}
18571869

1870+
private function signalReceivedEventForCommand(WorkflowRun $run, WorkflowCommand $command): ?WorkflowHistoryEvent
1871+
{
1872+
/** @var WorkflowHistoryEvent|null $event */
1873+
$event = $run->historyEvents->first(
1874+
static fn (WorkflowHistoryEvent $event): bool => $event->event_type === HistoryEventType::SignalReceived
1875+
&& $event->workflow_command_id === $command->id
1876+
);
1877+
1878+
return $event;
1879+
}
1880+
18581881
private function applySignal(
18591882
WorkflowRun $run,
18601883
WorkflowTask $task,
@@ -1923,11 +1946,7 @@ private function recordSignalWait(
19231946

19241947
private function signalWaitIdForCommand(WorkflowRun $run, WorkflowCommand $command, string $signalName): string
19251948
{
1926-
/** @var WorkflowHistoryEvent|null $receivedEvent */
1927-
$receivedEvent = $run->historyEvents->first(
1928-
static fn (WorkflowHistoryEvent $event): bool => $event->event_type === HistoryEventType::SignalReceived
1929-
&& $event->workflow_command_id === $command->id
1930-
);
1949+
$receivedEvent = $this->signalReceivedEventForCommand($run, $command);
19311950

19321951
$signalWaitId = $receivedEvent === null
19331952
? null

tests/Feature/V2/V2AwaitWorkflowTest.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,101 @@ public function testAwaitStringSignalTimeoutReturnsNullAndReplaysForQueries(): v
14351435
}
14361436
}
14371437

1438+
public function testAwaitStringSignalReceivedBeforeTimeoutFiredWinsByHistoryOrder(): void
1439+
{
1440+
Queue::fake();
1441+
1442+
try {
1443+
Carbon::setTestNow(Carbon::parse('2026-04-16 12:00:00'));
1444+
1445+
$workflow = WorkflowStub::make(TestAwaitSignalTimeoutWorkflow::class, 'await-signal-before-timeout-race');
1446+
$workflow->start('approved-by', 5);
1447+
1448+
$this->runReadyWorkflowTask($workflow->runId());
1449+
1450+
$signal = $workflow->attemptSignal('approved-by', 'Jordan');
1451+
1452+
$this->assertTrue($signal->accepted());
1453+
1454+
Carbon::setTestNow(now()->addSeconds(5));
1455+
1456+
$this->runReadyTimerTask($workflow->runId());
1457+
$this->runReadyWorkflowTask($workflow->runId());
1458+
1459+
$this->assertTrue($workflow->refresh()->completed());
1460+
$this->assertSame([
1461+
'payload' => 'Jordan',
1462+
'timed_out' => false,
1463+
'stage' => 'received',
1464+
'workflow_id' => 'await-signal-before-timeout-race',
1465+
'run_id' => $workflow->runId(),
1466+
], $workflow->output());
1467+
1468+
$eventTypes = WorkflowHistoryEvent::query()
1469+
->where('workflow_run_id', $workflow->runId())
1470+
->orderBy('sequence')
1471+
->pluck('event_type')
1472+
->map(static fn ($eventType) => $eventType->value)
1473+
->all();
1474+
1475+
$this->assertLessThan(
1476+
array_search('TimerFired', $eventTypes, true),
1477+
array_search('SignalReceived', $eventTypes, true),
1478+
);
1479+
$this->assertContains('SignalApplied', $eventTypes);
1480+
} finally {
1481+
Carbon::setTestNow();
1482+
}
1483+
}
1484+
1485+
public function testAwaitStringSignalTimeoutFiredBeforeSignalReceivedWinsByHistoryOrder(): void
1486+
{
1487+
Queue::fake();
1488+
1489+
try {
1490+
Carbon::setTestNow(Carbon::parse('2026-04-16 12:00:00'));
1491+
1492+
$workflow = WorkflowStub::make(TestAwaitSignalTimeoutWorkflow::class, 'await-timeout-before-signal-race');
1493+
$workflow->start('approved-by', 5);
1494+
1495+
$this->runReadyWorkflowTask($workflow->runId());
1496+
1497+
Carbon::setTestNow(now()->addSeconds(5));
1498+
1499+
$this->runReadyTimerTask($workflow->runId());
1500+
1501+
$signal = $workflow->attemptSignal('approved-by', 'Jordan');
1502+
1503+
$this->assertTrue($signal->accepted());
1504+
1505+
$this->runReadyWorkflowTask($workflow->runId());
1506+
1507+
$this->assertTrue($workflow->refresh()->completed());
1508+
$this->assertSame([
1509+
'payload' => null,
1510+
'timed_out' => true,
1511+
'stage' => 'timed-out',
1512+
'workflow_id' => 'await-timeout-before-signal-race',
1513+
'run_id' => $workflow->runId(),
1514+
], $workflow->output());
1515+
1516+
$eventTypes = WorkflowHistoryEvent::query()
1517+
->where('workflow_run_id', $workflow->runId())
1518+
->orderBy('sequence')
1519+
->pluck('event_type')
1520+
->map(static fn ($eventType) => $eventType->value)
1521+
->all();
1522+
1523+
$this->assertLessThan(
1524+
array_search('SignalReceived', $eventTypes, true),
1525+
array_search('TimerFired', $eventTypes, true),
1526+
);
1527+
$this->assertNotContains('SignalApplied', $eventTypes);
1528+
} finally {
1529+
Carbon::setTestNow();
1530+
}
1531+
}
1532+
14381533
public function testAwaitStringSignalPreservesEmptyAndMultipleArgumentPayloadRules(): void
14391534
{
14401535
Queue::fake();

0 commit comments

Comments
 (0)