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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
17 changes: 12 additions & 5 deletions src/V2/Support/TaskRepairCandidates.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
*/
final class TaskRepairCandidates
{
private const TIMESTAMP_FORMAT = 'Y-m-d H:i:s.u';

/**
* @return list<string>
*/
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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));
});
});

Expand Down Expand Up @@ -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));
});
}

Expand Down
59 changes: 59 additions & 0 deletions tests/Unit/V2/TaskRepairRecoveryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading