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..21c5ed02 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,15 +687,16 @@ 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() ->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(self::TIMESTAMP_FORMAT)); }) ->where(static function ($repairable) use ($now, $staleDispatchCutoff): void { $repairable @@ -710,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); + ->where( + 'repair_available_at', + '<=', + $now->format(self::TIMESTAMP_FORMAT) + ); }) ->orWhere(static function ($legacyClaimFailure) use ( $staleDispatchCutoff @@ -746,7 +753,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(self::TIMESTAMP_FORMAT)); }); }); @@ -885,7 +892,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(self::TIMESTAMP_FORMAT)); }); } diff --git a/tests/Unit/V2/TaskRepairRecoveryTest.php b/tests/Unit/V2/TaskRepairRecoveryTest.php index fcafd8c6..e9bce883 100644 --- a/tests/Unit/V2/TaskRepairRecoveryTest.php +++ b/tests/Unit/V2/TaskRepairRecoveryTest.php @@ -47,6 +47,65 @@ 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, + 'last_claim_failed_at' => now() + ->subMinute(), + 'last_claim_error' => 'claim unavailable', + ], + '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,