From 1dad3729febbea70b530d2153c8486d4e77803aa Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:55:55 -0600 Subject: [PATCH 1/5] Prepare Workflow 2.20.3 release --- CHANGELOG.md | 13 +++++++++++++ composer.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6101faf3a..645aa11f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +## 2.20.3 - 2026-11-04 + +- Rollback, embedded Workflow package: 2.20.3 changes no schema, history + event or public API. Rolling back restores the shared-cursor and empty + receive replay bugs. A kept stream now records receives at the current + step. Earlier versions look them up at the opening step and can replay + those runs differently after rollback. - A message on one stream stays receivable until it is consumed or expires (#1011). The run's message cursor is shared by signals, updates and every stream, so a signal, an update or a receive on another stream used to move @@ -24,6 +31,12 @@ its replay matches too. Consumptions that earlier releases recorded at the opened step still replay. +- Known limitation (#1024): a query can return nothing for a message an + update handler received, because the query replays the handler at the + wrong step. The query changes no message rows. The workflow's own replay + and completed output still return what the handler received. This issue + predates 2.20.3 and remains open. + ## 2.20.2 - 2026-11-04 - Rollback, embedded Workflow package: 2.20.2 changes no schema, history diff --git a/composer.json b/composer.json index 0169cbe19..db3ff7f3c 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.20.2", + "product-train": "2.20.3", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, From 91e2ffaf78597bce7d936b7fd995283216d41a93 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:24:02 -0600 Subject: [PATCH 2/5] Wait for stalled tracing collector requests before asserting delivery --- .../Unit/V2/ExporterExecutionTracingTest.php | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/Unit/V2/ExporterExecutionTracingTest.php b/tests/Unit/V2/ExporterExecutionTracingTest.php index efb8901e4..0d07c2f3b 100644 --- a/tests/Unit/V2/ExporterExecutionTracingTest.php +++ b/tests/Unit/V2/ExporterExecutionTracingTest.php @@ -64,7 +64,11 @@ public function testCollectorFailureDoesNotChangeColdWorkflowCompletion(string $ self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); } - $requests = $this->requests($directory); + // A timed-out exporter can return before the stalled collector + // has journaled every accepted request. + $requests = $mode === 'refused' + ? $this->requests($directory) + : $this->awaitRequests($directory, 4); self::assertCount($mode === 'refused' ? 0 : 4, $requests); foreach ($requests as $index => $request) { self::assertSame('POST', $request['method']); @@ -230,6 +234,21 @@ private function requests(string $directory): array ) : []; } + private function awaitRequests(string $directory, int $count): array + { + $deadline = hrtime(true) + 1_000_000_000; + + do { + $requests = $this->requests($directory); + if (count($requests) >= $count) { + return $requests; + } + usleep(1_000); + } while (hrtime(true) < $deadline); + + return $this->requests($directory); + } + private function worker(string $directory, string $mode, string $address): array { $process = new Process([ From 0d9c035840115173890c5aa8bdfdf43bb14023f1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:55:37 -0600 Subject: [PATCH 3/5] Settle tracing collector journal between cold workers --- .../Unit/V2/ExporterExecutionTracingTest.php | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/Unit/V2/ExporterExecutionTracingTest.php b/tests/Unit/V2/ExporterExecutionTracingTest.php index 0d07c2f3b..6a82b8605 100644 --- a/tests/Unit/V2/ExporterExecutionTracingTest.php +++ b/tests/Unit/V2/ExporterExecutionTracingTest.php @@ -42,12 +42,12 @@ public function testCollectorFailureDoesNotChangeColdWorkflowCompletion(string $ $server->stop(); } try { - $admitted = $this->worker($directory, 'admit', $address); + $admitted = $this->worker($directory, 'admit', $address, $mode === 'refused' ? 0 : 1); $workers = []; for ($i = 0; $i < 3; ++$i) { - $workers[] = $this->worker($directory, 'work', $address); + $workers[] = $this->worker($directory, 'work', $address, $mode === 'refused' ? 0 : $i + 2); } - $finished = $this->worker($directory, 'inspect', $address); + $finished = $this->worker($directory, 'inspect', $address, $mode === 'refused' ? 0 : 4); self::assertSame('completed', $finished['status']); self::assertSame([9007199254740993, null, '雪'], $finished['output']); self::assertSame(3, $finished['tasks']); @@ -64,11 +64,7 @@ public function testCollectorFailureDoesNotChangeColdWorkflowCompletion(string $ self::assertSame('4bf92f3577b34da6a3ce929d0e0e4736', $span['trace_id']); self::assertSame($admitted['run_id'], $span['fields']['workflow_run_id']); } - // A timed-out exporter can return before the stalled collector - // has journaled every accepted request. - $requests = $mode === 'refused' - ? $this->requests($directory) - : $this->awaitRequests($directory, 4); + $requests = $this->requests($directory); self::assertCount($mode === 'refused' ? 0 : 4, $requests); foreach ($requests as $index => $request) { self::assertSame('POST', $request['method']); @@ -249,8 +245,12 @@ private function awaitRequests(string $directory, int $count): array return $this->requests($directory); } - private function worker(string $directory, string $mode, string $address): array - { + private function worker( + string $directory, + string $mode, + string $address, + int $expectedRequestCount + ): array { $process = new Process([ PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', ...$this->autoloads(), @@ -268,6 +268,9 @@ private function worker(string $directory, string $mode, string $address): array if ($mode === 'work') { self::assertStringContainsString('Export failure', $process->getErrorOutput()); } + // Settle this worker's timed-out export before the next worker + // snapshots the shared journal to detect premature exports. + self::assertCount($expectedRequestCount, $this->awaitRequests($directory, $expectedRequestCount)); return $result; } From b2c867f6708d859a9b5aeb39e44774e397f082f4 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:11:24 -0600 Subject: [PATCH 4/5] Apply formatter to tracing worker helper --- tests/Unit/V2/ExporterExecutionTracingTest.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/Unit/V2/ExporterExecutionTracingTest.php b/tests/Unit/V2/ExporterExecutionTracingTest.php index 6a82b8605..80101c662 100644 --- a/tests/Unit/V2/ExporterExecutionTracingTest.php +++ b/tests/Unit/V2/ExporterExecutionTracingTest.php @@ -245,12 +245,8 @@ private function awaitRequests(string $directory, int $count): array return $this->requests($directory); } - private function worker( - string $directory, - string $mode, - string $address, - int $expectedRequestCount - ): array { + private function worker(string $directory, string $mode, string $address, int $expectedRequestCount): array + { $process = new Process([ PHP_BINARY, __DIR__ . '/../../Fixtures/V2/tracing-process.php', $directory, $mode, 'once', ...$this->autoloads(), From 13c010fd0621148ed6bad9ce2ccf4ada25d48451 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:28:50 -0600 Subject: [PATCH 5/5] Document the cleared task drain fix in 2.20.3 --- CHANGELOG.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 645aa11f8..9443ccf81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,9 @@ ## 2.20.3 - 2026-11-04 - Rollback, embedded Workflow package: 2.20.3 changes no schema, history - event or public API. Rolling back restores the shared-cursor and empty - receive replay bugs. A kept stream now records receives at the current - step. Earlier versions look them up at the opening step and can replay + event or public API. Rolling back restores the shared-cursor, empty + receive replay and fake task-drain precision bugs. A kept stream now + records receives at the current step. Earlier versions look them up at the opening step and can replay those runs differently after rollback. - A message on one stream stays receivable until it is consumed or expires (#1011). The run's message cursor is shared by signals, updates and every @@ -31,6 +31,12 @@ its replay matches too. Consumptions that earlier releases recorded at the opened step still replay. +- `WorkflowStub::runReadyTasks()` now drains fake-workflow tasks at their + exact due time, including fractional seconds (#1026). It compares the + sampled clock using the configured task model's persisted precision. + SQLite previously skipped tasks due at that instant because it compared + their fractional timestamps with a whole-second query value. Future + fractional deadlines remain pending until they are due. - Known limitation (#1024): a query can return nothing for a message an update handler received, because the query replays the handler at the wrong step. The query changes no message rows. The workflow's own replay