Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions src/V2/Support/WorkflowFiberContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand All @@ -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();
Expand Down
60 changes: 60 additions & 0 deletions tests/Feature/V2/V2DeterministicTimeReplayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand All @@ -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}."),
};

Expand Down
36 changes: 36 additions & 0 deletions tests/Fixtures/V2/TestDeterministicDeadlineWorkflow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures\V2;

use function Workflow\V2\timer;
use Workflow\V2\Workflow;

final class TestDeterministicDeadlineWorkflow extends Workflow
{
public int $startedAtMs;

public int $deadlineMs;

public ?int $completedAtMs = null;

public function handle(): array
{
$this->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,
];
}
}
85 changes: 84 additions & 1 deletion tests/Unit/V2/WorkflowFiberContextTimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(),
Expand Down