From 723db708e0c5a24c1e60dcc9a3649becd5a6ef67 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 12:19:08 +0000 Subject: [PATCH 1/5] Test fractional task repair eligibility boundaries --- tests/Unit/V2/TaskRepairRecoveryTest.php | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/Unit/V2/TaskRepairRecoveryTest.php b/tests/Unit/V2/TaskRepairRecoveryTest.php index fcafd8c6..17f92199 100644 --- a/tests/Unit/V2/TaskRepairRecoveryTest.php +++ b/tests/Unit/V2/TaskRepairRecoveryTest.php @@ -47,6 +47,49 @@ public static function delayedDispatches(): array ]; } + public static function fractionalRepairCutoffs(): array + { + $cases = []; + foreach (['lease', 'availability', 'dispatch backoff', 'claim backoff'] as $boundary) { + foreach ([-1 => false, 0 => true, 150000 => true] as $offset => $eligible) { + $cases[$boundary . ' offset ' . $offset] = [$boundary, $offset, $eligible]; + } + } + + return $cases; + } + + #[DataProvider('fractionalRepairCutoffs')] + public function testRepairCandidateSelectionPreservesFractionalDeadlines( + string $boundary, + int $offset, + bool $eligible, + ): void { + $run = $this->createRun(); + $deadline = now()->microsecond(114071); + $attributes = match ($boundary) { + 'lease' => ['status' => TaskStatus::Leased, 'lease_expires_at' => $deadline], + 'availability' => ['available_at' => $deadline], + 'dispatch backoff' => [ + 'last_dispatch_attempt_at' => now()->subMinute(), + 'last_dispatched_at' => null, + 'last_dispatch_error' => 'dispatch unavailable', + 'repair_available_at' => $deadline, + ], + 'claim backoff' => [ + 'last_claim_failed_at' => now()->subMinute(), + 'last_claim_error' => 'claim unavailable', + 'repair_available_at' => $deadline, + ], + }; + $task = $this->task($run, $attributes); + $before = $task->fresh()->getAttributes(); + Carbon::setTestNow($deadline->copy()->addMicroseconds($offset)); + + $this->assertSame($eligible ? [$task->id] : [], TaskRepairCandidates::taskIds(runIds: [$run->id])); + $this->assertSame($before, $task->fresh()->getAttributes()); + } + #[DataProvider('delayedDispatches')] public function testRedispatchWaitsForTheOriginalAvailabilityAndLatestDispatch( ?int $dispatchOffset, From 553fa6bb914986745e03d1956922e97a576aad3c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 12:22:50 +0000 Subject: [PATCH 2/5] Preserve fractional task repair cutoffs for 2.11.1 --- CHANGELOG.md | 6 ++++++ composer.json | 2 +- src/V2/Support/TaskRepairCandidates.php | 9 ++++---- tests/Unit/V2/TaskRepairRecoveryTest.php | 27 ++++++++++++++++++------ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eef00a9e..853c4a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.11.1 - 2026-10-22 + +- Preserve microsecond precision when selecting expired task leases, available + tasks and elapsed repair backoffs. A repair pass can select a task at its + recorded deadline without waiting for the next whole second. + ## 2.11.0 - 2026-10-22 - Add the opt-in `WorkflowOptions::overlapReleaseAfter` delay for legacy diff --git a/composer.json b/composer.json index a8f9666d..59792160 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.11.0", + "product-train": "2.11.1", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/src/V2/Support/TaskRepairCandidates.php b/src/V2/Support/TaskRepairCandidates.php index 5d0bb2eb..15ba875c 100644 --- a/src/V2/Support/TaskRepairCandidates.php +++ b/src/V2/Support/TaskRepairCandidates.php @@ -687,13 +687,14 @@ private static function existingTaskQuery( ->subSeconds(TaskRepairPolicy::redispatchAfterSeconds()) ->format('Y-m-d H:i:s.u'); + // DateTime query bindings lose the fractional precision stored by the task model. $query = WorkflowTask::query() ->where(static function ($query) use ($now, $staleDispatchCutoff): void { $query->where(static function ($ready) use ($now, $staleDispatchCutoff): void { $ready->where('status', TaskStatus::Ready->value) ->where(static function ($available) use ($now): void { $available->whereNull('available_at') - ->orWhere('available_at', '<=', $now); + ->orWhere('available_at', '<=', $now->format('Y-m-d H:i:s.u')); }) ->where(static function ($repairable) use ($now, $staleDispatchCutoff): void { $repairable @@ -710,7 +711,7 @@ private static function existingTaskQuery( $claimRepairable ->where(static function ($backoffReady) use ($now): void { $backoffReady->whereNotNull('repair_available_at') - ->where('repair_available_at', '<=', $now); + ->where('repair_available_at', '<=', $now->format('Y-m-d H:i:s.u')); }) ->orWhere(static function ($legacyClaimFailure) use ( $staleDispatchCutoff @@ -746,7 +747,7 @@ private static function existingTaskQuery( })->orWhere(static function ($leased) use ($now): void { $leased->where('status', TaskStatus::Leased->value) ->whereNotNull('lease_expires_at') - ->where('lease_expires_at', '<=', $now); + ->where('lease_expires_at', '<=', $now->format('Y-m-d H:i:s.u')); }); }); @@ -885,7 +886,7 @@ private static function applyRepairBackoffReady($query, CarbonInterface $now): v { $query->where(static function ($repairBackoff) use ($now): void { $repairBackoff->whereNull('repair_available_at') - ->orWhere('repair_available_at', '<=', $now); + ->orWhere('repair_available_at', '<=', $now->format('Y-m-d H:i:s.u')); }); } diff --git a/tests/Unit/V2/TaskRepairRecoveryTest.php b/tests/Unit/V2/TaskRepairRecoveryTest.php index 17f92199..e8f93439 100644 --- a/tests/Unit/V2/TaskRepairRecoveryTest.php +++ b/tests/Unit/V2/TaskRepairRecoveryTest.php @@ -51,7 +51,11 @@ public static function fractionalRepairCutoffs(): array { $cases = []; foreach (['lease', 'availability', 'dispatch backoff', 'claim backoff'] as $boundary) { - foreach ([-1 => false, 0 => true, 150000 => true] as $offset => $eligible) { + foreach ([ + -1 => false, + 0 => true, + 150000 => true, + ] as $offset => $eligible) { $cases[$boundary . ' offset ' . $offset] = [$boundary, $offset, $eligible]; } } @@ -66,24 +70,33 @@ public function testRepairCandidateSelectionPreservesFractionalDeadlines( bool $eligible, ): void { $run = $this->createRun(); - $deadline = now()->microsecond(114071); + $deadline = now() + ->microsecond(114071); $attributes = match ($boundary) { - 'lease' => ['status' => TaskStatus::Leased, 'lease_expires_at' => $deadline], - 'availability' => ['available_at' => $deadline], + 'lease' => [ + 'status' => TaskStatus::Leased, + 'lease_expires_at' => $deadline, + ], + 'availability' => [ + 'available_at' => $deadline, + ], 'dispatch backoff' => [ - 'last_dispatch_attempt_at' => now()->subMinute(), + 'last_dispatch_attempt_at' => now() + ->subMinute(), 'last_dispatched_at' => null, 'last_dispatch_error' => 'dispatch unavailable', 'repair_available_at' => $deadline, ], 'claim backoff' => [ - 'last_claim_failed_at' => now()->subMinute(), + 'last_claim_failed_at' => now() + ->subMinute(), 'last_claim_error' => 'claim unavailable', 'repair_available_at' => $deadline, ], }; $task = $this->task($run, $attributes); - $before = $task->fresh()->getAttributes(); + $before = $task->fresh() + ->getAttributes(); Carbon::setTestNow($deadline->copy()->addMicroseconds($offset)); $this->assertSame($eligible ? [$task->id] : [], TaskRepairCandidates::taskIds(runIds: [$run->id])); From cf6b616a042bd62c1644b5a6db07d789a0adb070 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 12:27:48 +0000 Subject: [PATCH 3/5] Exercise availability on a repairable failed claim --- tests/Unit/V2/TaskRepairRecoveryTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Unit/V2/TaskRepairRecoveryTest.php b/tests/Unit/V2/TaskRepairRecoveryTest.php index e8f93439..e9bce883 100644 --- a/tests/Unit/V2/TaskRepairRecoveryTest.php +++ b/tests/Unit/V2/TaskRepairRecoveryTest.php @@ -79,6 +79,9 @@ public function testRepairCandidateSelectionPreservesFractionalDeadlines( ], 'availability' => [ 'available_at' => $deadline, + 'last_claim_failed_at' => now() + ->subMinute(), + 'last_claim_error' => 'claim unavailable', ], 'dispatch backoff' => [ 'last_dispatch_attempt_at' => now() From 4f8d6ce651152181e6dd7c3375d4de4211c381be Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 12:31:24 +0000 Subject: [PATCH 4/5] Share the precise query timestamp format --- src/V2/Support/TaskRepairCandidates.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/V2/Support/TaskRepairCandidates.php b/src/V2/Support/TaskRepairCandidates.php index 15ba875c..fbae211e 100644 --- a/src/V2/Support/TaskRepairCandidates.php +++ b/src/V2/Support/TaskRepairCandidates.php @@ -23,6 +23,8 @@ */ final class TaskRepairCandidates { + private const TIMESTAMP_FORMAT = 'Y-m-d H:i:s.u'; + /** * @return list */ @@ -685,7 +687,7 @@ private static function existingTaskQuery( $now ??= now(); $staleDispatchCutoff = $now->copy() ->subSeconds(TaskRepairPolicy::redispatchAfterSeconds()) - ->format('Y-m-d H:i:s.u'); + ->format(self::TIMESTAMP_FORMAT); // DateTime query bindings lose the fractional precision stored by the task model. $query = WorkflowTask::query() @@ -694,7 +696,7 @@ private static function existingTaskQuery( $ready->where('status', TaskStatus::Ready->value) ->where(static function ($available) use ($now): void { $available->whereNull('available_at') - ->orWhere('available_at', '<=', $now->format('Y-m-d H:i:s.u')); + ->orWhere('available_at', '<=', $now->format(self::TIMESTAMP_FORMAT)); }) ->where(static function ($repairable) use ($now, $staleDispatchCutoff): void { $repairable @@ -711,7 +713,7 @@ private static function existingTaskQuery( $claimRepairable ->where(static function ($backoffReady) use ($now): void { $backoffReady->whereNotNull('repair_available_at') - ->where('repair_available_at', '<=', $now->format('Y-m-d H:i:s.u')); + ->where('repair_available_at', '<=', $now->format(self::TIMESTAMP_FORMAT)); }) ->orWhere(static function ($legacyClaimFailure) use ( $staleDispatchCutoff @@ -747,7 +749,7 @@ private static function existingTaskQuery( })->orWhere(static function ($leased) use ($now): void { $leased->where('status', TaskStatus::Leased->value) ->whereNotNull('lease_expires_at') - ->where('lease_expires_at', '<=', $now->format('Y-m-d H:i:s.u')); + ->where('lease_expires_at', '<=', $now->format(self::TIMESTAMP_FORMAT)); }); }); @@ -886,7 +888,7 @@ private static function applyRepairBackoffReady($query, CarbonInterface $now): v { $query->where(static function ($repairBackoff) use ($now): void { $repairBackoff->whereNull('repair_available_at') - ->orWhere('repair_available_at', '<=', $now->format('Y-m-d H:i:s.u')); + ->orWhere('repair_available_at', '<=', $now->format(self::TIMESTAMP_FORMAT)); }); } From cd6e372b3cca9c368f0e8db178bb080bd57ddaee Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 22 Oct 2026 12:39:51 +0000 Subject: [PATCH 5/5] Wrap the precise backoff query to repository style --- src/V2/Support/TaskRepairCandidates.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/V2/Support/TaskRepairCandidates.php b/src/V2/Support/TaskRepairCandidates.php index fbae211e..21c5ed02 100644 --- a/src/V2/Support/TaskRepairCandidates.php +++ b/src/V2/Support/TaskRepairCandidates.php @@ -713,7 +713,11 @@ private static function existingTaskQuery( $claimRepairable ->where(static function ($backoffReady) use ($now): void { $backoffReady->whereNotNull('repair_available_at') - ->where('repair_available_at', '<=', $now->format(self::TIMESTAMP_FORMAT)); + ->where( + 'repair_available_at', + '<=', + $now->format(self::TIMESTAMP_FORMAT) + ); }) ->orWhere(static function ($legacyClaimFailure) use ( $staleDispatchCutoff