From 77a1d604593851c798366463eec7024761bc8051 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 12 Sep 2026 09:12:53 +0000 Subject: [PATCH 1/2] fix: isolate deterministic workflow clock values --- src/V2/Support/WorkflowFiberContext.php | 4 +- .../V2/V2DeterministicTimeReplayTest.php | 60 +++++++++++++ .../V2/TestDeterministicDeadlineWorkflow.php | 36 ++++++++ .../Unit/V2/WorkflowFiberContextTimeTest.php | 85 ++++++++++++++++++- 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/V2/TestDeterministicDeadlineWorkflow.php diff --git a/src/V2/Support/WorkflowFiberContext.php b/src/V2/Support/WorkflowFiberContext.php index 0b724f3e..bf351e6f 100644 --- a/src/V2/Support/WorkflowFiberContext.php +++ b/src/V2/Support/WorkflowFiberContext.php @@ -72,7 +72,7 @@ public static function setTime(CarbonInterface $time, ?Fiber $fiber = null): voi $fiber ??= Fiber::getCurrent(); if ($fiber instanceof Fiber) { - self::$workflowTime[spl_object_id($fiber)] = $time; + self::$workflowTime[spl_object_id($fiber)] = $time->copy(); } } @@ -88,7 +88,7 @@ public static function getTime(): CarbonInterface $fiber = Fiber::getCurrent(); if ($fiber instanceof Fiber && isset(self::$workflowTime[spl_object_id($fiber)])) { - return self::$workflowTime[spl_object_id($fiber)]; + return self::$workflowTime[spl_object_id($fiber)]->copy(); } return now(); diff --git a/tests/Feature/V2/V2DeterministicTimeReplayTest.php b/tests/Feature/V2/V2DeterministicTimeReplayTest.php index d9eb2e70..30fff66c 100644 --- a/tests/Feature/V2/V2DeterministicTimeReplayTest.php +++ b/tests/Feature/V2/V2DeterministicTimeReplayTest.php @@ -6,12 +6,14 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Queue; +use Tests\Fixtures\V2\TestDeterministicDeadlineWorkflow; use Tests\Fixtures\V2\TestReplayDeterministicTimeWorkflow; use Tests\TestCase; use Workflow\V2\Enums\HistoryEventType; use Workflow\V2\Enums\TaskStatus; use Workflow\V2\Enums\TaskType; use Workflow\V2\Jobs\RunActivityTask; +use Workflow\V2\Jobs\RunTimerTask; use Workflow\V2\Jobs\RunWorkflowTask; use Workflow\V2\Models\WorkflowHistoryEvent; use Workflow\V2\Models\WorkflowRun; @@ -116,6 +118,63 @@ public function testReplayReadsSeededEventTimeAndIgnoresAmbientWallClock(): void ); } + public function testDerivedDeadlineWaitsForDurableTimeAcrossFreshDatabaseReplays(): void + { + $startedAt = Carbon::parse('2026-02-01T12:00:00Z'); + Carbon::setTestNow($startedAt); + + try { + $workflow = WorkflowStub::make(TestDeterministicDeadlineWorkflow::class, 'deterministic-deadline'); + $workflow->start(); + $this->runReadyTaskOfType(TaskType::Workflow); + + $this->assertSame('waiting', $workflow->refresh()->status()); + $this->assertDatabaseMissing('workflow_history_events', [ + 'workflow_run_id' => $workflow->runId(), + 'event_type' => HistoryEventType::WorkflowCompleted->value, + ]); + + Carbon::setTestNow(Carbon::parse('2099-12-31T23:59:59Z')); + $replay = (new WorkflowReplayer())->replay(WorkflowRun::query()->findOrFail($workflow->runId())); + $this->assertInstanceOf(TestDeterministicDeadlineWorkflow::class, $replay->workflow); + $this->assertSame($startedAt->getTimestampMs(), $replay->workflow->startedAtMs); + $this->assertSame($startedAt->copy()->addHour()->getTimestampMs(), $replay->workflow->deadlineMs); + $this->assertNull($replay->workflow->completedAtMs); + unset($replay); + + Carbon::setTestNow($startedAt->copy()->addMinutes(30)); + $this->runReadyTaskOfType(TaskType::Timer); + $this->runReadyTaskOfType(TaskType::Workflow); + $this->assertSame('waiting', $workflow->refresh()->status()); + + Carbon::setTestNow($startedAt->copy()->addHour()); + $this->runReadyTaskOfType(TaskType::Timer); + $this->runReadyTaskOfType(TaskType::Workflow); + $this->assertTrue($workflow->refresh()->completed()); + $this->assertSame([ + 'started_at_ms' => $startedAt->getTimestampMs(), + 'deadline_ms' => $startedAt->copy() + ->addHour() + ->getTimestampMs(), + 'completed_at_ms' => $startedAt->copy() + ->addHour() + ->getTimestampMs(), + ], $workflow->output()); + + $this->assertSame(2, WorkflowHistoryEvent::query() + ->where('workflow_run_id', $workflow->runId()) + ->where('event_type', HistoryEventType::TimerFired->value) + ->count()); + + Carbon::setTestNow(Carbon::parse('2099-12-31T23:59:59Z')); + $replay = (new WorkflowReplayer())->replay(WorkflowRun::query()->findOrFail($workflow->runId())); + $this->assertInstanceOf(TestDeterministicDeadlineWorkflow::class, $replay->workflow); + $this->assertSame($workflow->output()['completed_at_ms'], $replay->workflow->completedAtMs); + } finally { + Carbon::setTestNow(null); + } + } + private function runReadyTaskOfType(TaskType $taskType): void { /** @var WorkflowTask|null $task */ @@ -132,6 +191,7 @@ private function runReadyTaskOfType(TaskType $taskType): void $job = match ($task->task_type) { TaskType::Workflow => new RunWorkflowTask($task->id), TaskType::Activity => new RunActivityTask($task->id), + TaskType::Timer => new RunTimerTask($task->id), default => $this->fail("Unsupported task type {$task->task_type->value}."), }; diff --git a/tests/Fixtures/V2/TestDeterministicDeadlineWorkflow.php b/tests/Fixtures/V2/TestDeterministicDeadlineWorkflow.php new file mode 100644 index 00000000..4b948c04 --- /dev/null +++ b/tests/Fixtures/V2/TestDeterministicDeadlineWorkflow.php @@ -0,0 +1,36 @@ +startedAtMs = self::now()->getTimestampMs(); + $deadline = self::now()->addHour(); + $this->deadlineMs = $deadline->getTimestampMs(); + + while (self::now()->lessThan($deadline)) { + timer(1800); + } + + $this->completedAtMs = self::now()->getTimestampMs(); + + return [ + 'started_at_ms' => $this->startedAtMs, + 'deadline_ms' => $this->deadlineMs, + 'completed_at_ms' => $this->completedAtMs, + ]; + } +} diff --git a/tests/Unit/V2/WorkflowFiberContextTimeTest.php b/tests/Unit/V2/WorkflowFiberContextTimeTest.php index 6e75b70e..f399599a 100644 --- a/tests/Unit/V2/WorkflowFiberContextTimeTest.php +++ b/tests/Unit/V2/WorkflowFiberContextTimeTest.php @@ -8,10 +8,92 @@ use Fiber; use Illuminate\Support\Carbon; use Tests\NonDatabaseTestCase; +use function Workflow\V2\now; use Workflow\V2\Support\WorkflowFiberContext; final class WorkflowFiberContextTimeTest extends NonDatabaseTestCase { + public function testDeadlineArithmeticDoesNotAdvanceTheWorkflowClock(): void + { + $fiber = new Fiber(function (): void { + WorkflowFiberContext::enter(); + + try { + WorkflowFiberContext::setTime(Carbon::parse('2026-01-01T00:00:00.123456+05:30')); + $first = now(); + $second = now(); + + $this->assertNotSame($first, $second); + $this->assertSame($first->format('Y-m-d H:i:s.uP'), $second->format('Y-m-d H:i:s.uP')); + + $deadline = $first->addHour(); + + $this->assertSame('2026-01-01 00:00:00.123456+05:30', now()->format('Y-m-d H:i:s.uP')); + $this->assertSame('2026-01-01 00:00:00.123456+05:30', $second->format('Y-m-d H:i:s.uP')); + $this->assertFalse(now()->greaterThanOrEqualTo($deadline)); + } finally { + WorkflowFiberContext::leave(); + } + }); + + $fiber->start(); + } + + public function testSetTimeDoesNotRetainTheCallersMutableObject(): void + { + $fiber = new Fiber(function (): void { + WorkflowFiberContext::enter(); + + try { + $event = Carbon::parse('2026-01-01T00:00:00.123456Z'); + WorkflowFiberContext::setTime($event); + $event->addHour(); + + $this->assertSame('2026-01-01 00:00:00.123456', now()->format('Y-m-d H:i:s.u')); + } finally { + WorkflowFiberContext::leave(); + } + }); + + $fiber->start(); + } + + public function testExplicitFiberClocksRemainIndependentWhenCallerAndReadValuesChange(): void + { + $event = Carbon::parse('2026-01-01T00:00:00Z'); + $first = new Fiber(static function (): void { + WorkflowFiberContext::enter(); + + try { + now()->addDay(); + } finally { + WorkflowFiberContext::leave(); + } + }); + $second = new Fiber(function (): void { + WorkflowFiberContext::enter(); + + try { + $this->assertSame('2026-01-01T00:00:00+00:00', now()->toIso8601String()); + Fiber::suspend(); + $this->assertSame('2026-01-01T01:00:00+00:00', now()->toIso8601String()); + } finally { + WorkflowFiberContext::leave(); + } + }); + + WorkflowFiberContext::setTime($event, $first); + WorkflowFiberContext::setTime($event, $second); + $event->addWeek(); + $first->start(); + $second->start(); + + $nextEvent = Carbon::parse('2026-01-01T01:00:00Z'); + WorkflowFiberContext::setTime($nextEvent, $second); + $nextEvent->addDay(); + $second->resume(); + } + public function testGetTimeFallsBackToWallClockOutsideFiber(): void { $frozen = Carbon::parse('2026-01-01T12:00:00Z'); @@ -49,7 +131,8 @@ public function testSetTimeWithFiberArgumentStoresPerFiberTime(): void $fiber->resume(); - $this->assertInstanceOf(\Carbon\CarbonInterface::class, $observed); + $this->assertInstanceOf(CarbonImmutable::class, $observed); + $this->assertNotSame($event, $observed); $this->assertSame( $event->getTimestampMs(), $observed->getTimestampMs(), From 28bc3eeb9612d429bb626e90a8320c50c1598d1c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Sat, 12 Sep 2026 09:15:13 +0000 Subject: [PATCH 2/2] Prepare deterministic clock patch release 2.0.14 --- CHANGELOG.md | 9 +++++++++ composer.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8a1aa0..7f6ff86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +## 2.0.14 - 2026-09-12 + +- Isolate deterministic workflow clock values from caller mutation. Deriving a + deadline with `Workflow::now()->addHour()` no longer advances workflow time or + expires the deadline immediately. Mutating a timestamp supplied to the fiber + context cannot change its stored time either. History-based clock advancement, + timestamp precision, timezones, and mutable or immutable Carbon types remain + unchanged. + ## 2.0.13 - 2026-09-11 - Reduce repeated database lookups during full timeline, wait, and timer diff --git a/composer.json b/composer.json index 40ba6309..b4726e17 100644 --- a/composer.json +++ b/composer.json @@ -79,7 +79,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.0.13", + "product-train": "2.0.14", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" },