Skip to content

Commit 30974a3

Browse files
authored
Merge pull request #905 from durable-workflow/fix/878-precise-repair-cutoff
Preserve fractional task repair deadlines (2.11.1)
2 parents d7db4a2 + cd6e372 commit 30974a3

4 files changed

Lines changed: 78 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 2.11.1 - 2026-10-22
4+
5+
- Preserve microsecond precision when selecting expired task leases, available
6+
tasks and elapsed repair backoffs. A repair pass can select a task at its
7+
recorded deadline without waiting for the next whole second.
8+
39
## 2.11.0 - 2026-10-22
410

511
- Add the opt-in `WorkflowOptions::overlapReleaseAfter` delay for legacy

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
"dev-main": "2.0.x-dev"
8888
},
8989
"durable-workflow": {
90-
"product-train": "2.11.0",
90+
"product-train": "2.11.1",
9191
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
9292
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
9393
},

‎src/V2/Support/TaskRepairCandidates.php‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
*/
2424
final class TaskRepairCandidates
2525
{
26+
private const TIMESTAMP_FORMAT = 'Y-m-d H:i:s.u';
27+
2628
/**
2729
* @return list<string>
2830
*/
@@ -685,15 +687,16 @@ private static function existingTaskQuery(
685687
$now ??= now();
686688
$staleDispatchCutoff = $now->copy()
687689
->subSeconds(TaskRepairPolicy::redispatchAfterSeconds())
688-
->format('Y-m-d H:i:s.u');
690+
->format(self::TIMESTAMP_FORMAT);
689691

692+
// DateTime query bindings lose the fractional precision stored by the task model.
690693
$query = WorkflowTask::query()
691694
->where(static function ($query) use ($now, $staleDispatchCutoff): void {
692695
$query->where(static function ($ready) use ($now, $staleDispatchCutoff): void {
693696
$ready->where('status', TaskStatus::Ready->value)
694697
->where(static function ($available) use ($now): void {
695698
$available->whereNull('available_at')
696-
->orWhere('available_at', '<=', $now);
699+
->orWhere('available_at', '<=', $now->format(self::TIMESTAMP_FORMAT));
697700
})
698701
->where(static function ($repairable) use ($now, $staleDispatchCutoff): void {
699702
$repairable
@@ -710,7 +713,11 @@ private static function existingTaskQuery(
710713
$claimRepairable
711714
->where(static function ($backoffReady) use ($now): void {
712715
$backoffReady->whereNotNull('repair_available_at')
713-
->where('repair_available_at', '<=', $now);
716+
->where(
717+
'repair_available_at',
718+
'<=',
719+
$now->format(self::TIMESTAMP_FORMAT)
720+
);
714721
})
715722
->orWhere(static function ($legacyClaimFailure) use (
716723
$staleDispatchCutoff
@@ -746,7 +753,7 @@ private static function existingTaskQuery(
746753
})->orWhere(static function ($leased) use ($now): void {
747754
$leased->where('status', TaskStatus::Leased->value)
748755
->whereNotNull('lease_expires_at')
749-
->where('lease_expires_at', '<=', $now);
756+
->where('lease_expires_at', '<=', $now->format(self::TIMESTAMP_FORMAT));
750757
});
751758
});
752759

@@ -885,7 +892,7 @@ private static function applyRepairBackoffReady($query, CarbonInterface $now): v
885892
{
886893
$query->where(static function ($repairBackoff) use ($now): void {
887894
$repairBackoff->whereNull('repair_available_at')
888-
->orWhere('repair_available_at', '<=', $now);
895+
->orWhere('repair_available_at', '<=', $now->format(self::TIMESTAMP_FORMAT));
889896
});
890897
}
891898

‎tests/Unit/V2/TaskRepairRecoveryTest.php‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,65 @@ public static function delayedDispatches(): array
4747
];
4848
}
4949

50+
public static function fractionalRepairCutoffs(): array
51+
{
52+
$cases = [];
53+
foreach (['lease', 'availability', 'dispatch backoff', 'claim backoff'] as $boundary) {
54+
foreach ([
55+
-1 => false,
56+
0 => true,
57+
150000 => true,
58+
] as $offset => $eligible) {
59+
$cases[$boundary . ' offset ' . $offset] = [$boundary, $offset, $eligible];
60+
}
61+
}
62+
63+
return $cases;
64+
}
65+
66+
#[DataProvider('fractionalRepairCutoffs')]
67+
public function testRepairCandidateSelectionPreservesFractionalDeadlines(
68+
string $boundary,
69+
int $offset,
70+
bool $eligible,
71+
): void {
72+
$run = $this->createRun();
73+
$deadline = now()
74+
->microsecond(114071);
75+
$attributes = match ($boundary) {
76+
'lease' => [
77+
'status' => TaskStatus::Leased,
78+
'lease_expires_at' => $deadline,
79+
],
80+
'availability' => [
81+
'available_at' => $deadline,
82+
'last_claim_failed_at' => now()
83+
->subMinute(),
84+
'last_claim_error' => 'claim unavailable',
85+
],
86+
'dispatch backoff' => [
87+
'last_dispatch_attempt_at' => now()
88+
->subMinute(),
89+
'last_dispatched_at' => null,
90+
'last_dispatch_error' => 'dispatch unavailable',
91+
'repair_available_at' => $deadline,
92+
],
93+
'claim backoff' => [
94+
'last_claim_failed_at' => now()
95+
->subMinute(),
96+
'last_claim_error' => 'claim unavailable',
97+
'repair_available_at' => $deadline,
98+
],
99+
};
100+
$task = $this->task($run, $attributes);
101+
$before = $task->fresh()
102+
->getAttributes();
103+
Carbon::setTestNow($deadline->copy()->addMicroseconds($offset));
104+
105+
$this->assertSame($eligible ? [$task->id] : [], TaskRepairCandidates::taskIds(runIds: [$run->id]));
106+
$this->assertSame($before, $task->fresh()->getAttributes());
107+
}
108+
50109
#[DataProvider('delayedDispatches')]
51110
public function testRedispatchWaitsForTheOriginalAvailabilityAndLatestDispatch(
52111
?int $dispatchOffset,

0 commit comments

Comments
 (0)