Skip to content

Commit 78075b6

Browse files
authored
Merge pull request #839 from durable-workflow/release/2.5.32-sqlite-write-waits
Release 2.5.32: preserve SQLite claim and acknowledgment retry budgets
2 parents 3b5e58c + 299d748 commit 78075b6

9 files changed

Lines changed: 322 additions & 10 deletions

‎CHANGELOG.md‎

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

3+
## 2.5.32 - 2026-10-17
4+
5+
- Keep individual SQLite writer waits within the existing claim and queue
6+
acknowledgment retry budgets, so a normal busy timeout leaves time to retry.
7+
- Restore the connection's configured wait before workflow code and after a
8+
refused write. Preserve original reservation fences, claims and deadlines.
9+
- Retry only proven root rollbacks. Do not repeat handlers or activity callbacks,
10+
or retry uncertain commit and rollback outcomes.
11+
312
## 2.5.31 - 2026-10-17
413

514
- Admit SQLite summary writes before projection reads and model hooks so proven

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
"dev-main": "2.0.x-dev"
8080
},
8181
"durable-workflow": {
82-
"product-train": "2.5.31",
82+
"product-train": "2.5.32",
8383
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
8484
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
8585
},

‎src/Queue/SqliteAcknowledgingDatabaseQueue.php‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44

55
namespace Workflow\Queue;
66

7+
use Illuminate\Database\Query\Builder;
78
use Illuminate\Database\QueryException;
89
use Illuminate\Queue\DatabaseQueue;
910
use PDO;
1011
use PDOException;
1112
use Throwable;
13+
use Workflow\Support\SqliteWriteWait;
1214

1315
/** Keep Laravel's pop lifecycle while acknowledging only the original reservation. */
1416
class SqliteAcknowledgingDatabaseQueue extends DatabaseQueue
@@ -20,6 +22,8 @@ public function deleteClaimedJob(string $queue, int|string $id, int $reservedAt,
2022
$rootTransaction = $this->database->transactionLevel() === 0;
2123
$pdo = $this->database->getPdo();
2224
$deadline = hrtime(true) + 5_000_000_000;
25+
$boundedWait = $rootTransaction && $pdo instanceof PDO
26+
&& $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
2327

2428
for ($attempt = 0; $attempt < self::MAX_DELETE_ATTEMPTS; $attempt++) {
2529
$deleteFailure = null;
@@ -33,11 +37,17 @@ public function deleteClaimedJob(string $queue, int|string $id, int $reservedAt,
3337
$bindings = $query->getBindings();
3438

3539
try {
36-
$this->database->transaction(static function () use ($query, &$deleteFailure): void {
40+
$this->database->transaction(static function () use (
41+
$query,
42+
&$deleteFailure,
43+
$boundedWait,
44+
$pdo,
45+
$deadline,
46+
): void {
3747
try {
3848
// One conditional write avoids SQLite's SELECT-to-write upgrade.
3949
// A newer reservation cannot match the original claim.
40-
$query->delete();
50+
self::deleteWithBoundedWait($query, $pdo, $boundedWait, $deadline);
4151
} catch (QueryException $exception) {
4252
$deleteFailure = $exception;
4353
throw $exception;
@@ -83,4 +93,15 @@ protected function marshalJob($queue, $job)
8393
$queue,
8494
);
8595
}
96+
97+
private static function deleteWithBoundedWait(Builder $query, PDO $pdo, bool $boundedWait, int $deadline): void
98+
{
99+
if ($boundedWait) {
100+
SqliteWriteWait::run($pdo, static function () use ($query): void {
101+
$query->delete();
102+
}, $deadline);
103+
return;
104+
}
105+
$query->delete();
106+
}
86107
}

‎src/Support/SqliteWriteWait.php‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\Support;
6+
7+
use Closure;
8+
use PDO;
9+
10+
/** @internal The caller owns transaction proof, write identity and retry policy. */
11+
final class SqliteWriteWait
12+
{
13+
/**
14+
* @param Closure(): void $write
15+
*/
16+
public static function run(PDO $pdo, Closure $write, int $deadline): void
17+
{
18+
$statement = $pdo->query('PRAGMA busy_timeout');
19+
if ($statement === false) {
20+
throw new SqliteWriteWaitException('Cannot inspect the SQLite admission busy timeout.');
21+
}
22+
$originalTimeout = (int) $statement->fetchColumn();
23+
$statement->closeCursor();
24+
$waitMilliseconds = min(100, max(1, (int) (($deadline - hrtime(true)) / 1_000_000)));
25+
if ($originalTimeout <= $waitMilliseconds) {
26+
$write();
27+
return;
28+
}
29+
30+
// A single driver wait must not consume the caller's retry budget.
31+
// Restore the setting before application code or the next operation.
32+
$pdo->exec('PRAGMA busy_timeout=' . $waitMilliseconds);
33+
try {
34+
$write();
35+
} finally {
36+
$pdo->exec('PRAGMA busy_timeout=' . $originalTimeout);
37+
}
38+
}
39+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\Support;
6+
7+
use RuntimeException;
8+
9+
/** @internal A safe write cannot proceed without inspecting its driver wait. */
10+
final class SqliteWriteWaitException extends RuntimeException
11+
{
12+
}

‎src/V2/Support/SqliteTransaction.php‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PDO;
1212
use PDOException;
1313
use Throwable;
14+
use Workflow\Support\SqliteWriteWait;
1415

1516
final class SqliteTransaction
1617
{
@@ -49,7 +50,7 @@ public static function run(
4950

5051
[$maximumAttempts, $deadline] = self::retryLimits($attempts, $admission !== null, $admissionDeadline);
5152
for ($attempt = 1; ; $attempt++) {
52-
$outcome = self::attempt($connection, $pdo, $operation, $admission);
53+
$outcome = self::attempt($connection, $pdo, $operation, $admission, $deadline);
5354
if (! isset($outcome['error'])) {
5455
return $outcome['value'];
5556
}
@@ -124,8 +125,13 @@ private static function retryLimits(?int $attempts, bool $admission, ?int $admis
124125
* @param Closure(Connection): void|null $admission
125126
* @return array{value: T}|array{error: Throwable, retry: bool}
126127
*/
127-
private static function attempt(Connection $connection, PDO $pdo, Closure $operation, ?Closure $admission): array
128-
{
128+
private static function attempt(
129+
Connection $connection,
130+
PDO $pdo,
131+
Closure $operation,
132+
?Closure $admission,
133+
int $deadline,
134+
): array {
129135
$failedInRoot = false;
130136
$returned = false;
131137
$entered = false;
@@ -135,14 +141,17 @@ private static function attempt(Connection $connection, PDO $pdo, Closure $opera
135141
$operation,
136142
$admission,
137143
$pdo,
144+
$deadline,
138145
&$failedInRoot,
139146
&$returned,
140147
&$entered,
141148
&$mutationError
142149
): mixed {
143150
try {
144151
if ($admission !== null) {
145-
$admission($database);
152+
SqliteWriteWait::run($pdo, static function () use ($admission, $database): void {
153+
$admission($database);
154+
}, $deadline);
146155
}
147156
$entered = true;
148157
$result = $operation($database);

‎tests/Unit/Queue/SqliteDatabaseAcknowledgementTest.php‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,44 @@ public function testSustainedBusyStopsWithinTheRetryWindowWithTheOriginalReserva
123123
self::assertEquals($original, $this->connection->table('jobs')->find($job->getJobId()));
124124
}
125125

126+
#[DataProvider('writerReleasePoints')]
127+
public function testNormalBusyTimeoutLeavesTimeForFencedAcknowledgementRetries(int $releaseAfter): void
128+
{
129+
$pdo = $this->connection->getPdo();
130+
$pdo->exec('PRAGMA busy_timeout=5000');
131+
$job = $this->job();
132+
$neighbor = $this->push();
133+
$neighborBefore = $this->connection->table('jobs')
134+
->find($neighbor);
135+
$writer = $this->blockDelete();
136+
$rollbacks = 0;
137+
$this->events->listen(TransactionRolledBack::class, function () use (
138+
$writer,
139+
$releaseAfter,
140+
&$rollbacks
141+
): void {
142+
self::assertSame(1, AcknowledgementReceiptJob::$handled);
143+
self::assertSame(0, $this->connection->transactionLevel());
144+
self::assertFalse($this->connection->getPdo()->inTransaction());
145+
if (++$rollbacks === $releaseAfter) {
146+
$writer->rollBack();
147+
}
148+
});
149+
try {
150+
$job->fire();
151+
} finally {
152+
if ($writer->inTransaction()) {
153+
$writer->rollBack();
154+
}
155+
}
156+
self::assertSame($releaseAfter, $rollbacks);
157+
self::assertSame($releaseAfter + 1, $this->deleteStatements);
158+
self::assertSame(1, AcknowledgementReceiptJob::$handled);
159+
self::assertNull($this->connection->table('jobs')->find($job->getJobId()));
160+
self::assertEquals($neighborBefore, $this->connection->table('jobs')->find($neighbor));
161+
self::assertSame(5000, (int) $pdo->query('PRAGMA busy_timeout')->fetchColumn());
162+
}
163+
126164
#[DataProvider('writerReleasePoints')]
127165
public function testAReclaimedReservationSurvivesTheOriginalAcknowledgementRetry(int $releaseAfter): void
128166
{

‎tests/Unit/V2/EmbeddedSqliteTaskContentionTest.php‎

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,109 @@ public static function claims(): array
8787
];
8888
}
8989

90+
#[DataProvider('claims')]
91+
public function testOrdinaryBusyTimeoutLeavesTimeToRetryTheUnclaimedDelivery(string $kind, bool $separate): void
92+
{
93+
config()->set('logging.default', 'null');
94+
if ($separate) {
95+
config()->set('database.connections.application_sqlite', [
96+
'driver' => 'sqlite',
97+
'database' => ':memory:',
98+
'prefix' => '',
99+
'foreign_key_constraints' => true,
100+
]);
101+
DB::setDefaultConnection('application_sqlite');
102+
$this->createQueueTables(DB::connection());
103+
config()
104+
->set('queue.failed.database', 'application_sqlite');
105+
}
106+
$stub = WorkflowStub::make(SqliteContentionWorkflow::class, 'ordinary-busy-' . $kind, 'contention-test');
107+
$stub->start(false, false, new StartOptions(executionTimeoutSeconds: 120, runTimeoutSeconds: 100));
108+
if ($kind === 'activity') {
109+
$this->workOnce();
110+
}
111+
$task = WorkflowTask::query()->where('workflow_run_id', $stub->runId())
112+
->where('task_type', $kind)
113+
->sole();
114+
$run = $task->run;
115+
$deadlines = [$run->execution_deadline_at->toJSON(), $run->run_deadline_at->toJSON()];
116+
$prefix = WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)->orderBy('sequence')
117+
->get()
118+
->map->getAttributes()
119+
->all();
120+
$connection = DB::connection('contention_sqlite');
121+
$connection->getPdo()
122+
->exec('PRAGMA busy_timeout=5000');
123+
$writer = new PDO('sqlite:' . $this->path);
124+
$writer->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
125+
$injected = false;
126+
$rollbacks = 0;
127+
$connection->beforeExecuting(static function (string $sql) use ($writer, &$injected): void {
128+
if ($injected || ! str_starts_with($sql, 'update "workflow_tasks" set "id" =')) {
129+
return;
130+
}
131+
$writer->beginTransaction();
132+
$writer->exec('INSERT INTO contention_probe (id) VALUES (1)');
133+
$injected = true;
134+
});
135+
$connection->getEventDispatcher()
136+
->listen(TransactionRolledBack::class, static function ($event) use (
137+
$connection,
138+
$writer,
139+
&$rollbacks
140+
): void {
141+
if ($event->connection === $connection && $writer->inTransaction()) {
142+
self::assertSame(0, $connection->transactionLevel());
143+
self::assertFalse($connection->getPdo()->inTransaction());
144+
self::assertSame(0, SqliteContentionActivity::$invocations);
145+
$rollbacks++;
146+
$writer->commit();
147+
}
148+
});
149+
try {
150+
$this->workOnce();
151+
} finally {
152+
if ($writer->inTransaction()) {
153+
$writer->rollBack();
154+
}
155+
}
156+
self::assertTrue($injected);
157+
self::assertSame(1, $rollbacks);
158+
self::assertSame(5000, (int) $connection->getPdo()->query('PRAGMA busy_timeout')->fetchColumn());
159+
self::assertSame(
160+
0,
161+
DB::table('failed_jobs')->count(),
162+
'A rolled-back pre-claim wait must leave time to retry.'
163+
);
164+
self::assertSame('completed', $task->fresh()->status->value);
165+
self::assertSame(1, (int) $task->fresh()->attempt_count);
166+
for ($remaining = 0; $remaining < 3 && $stub->refresh()->status() !== 'completed'; $remaining++) {
167+
$this->workOnce();
168+
}
169+
self::assertSame('completed', $stub->refresh()->status());
170+
self::assertSame([
171+
'integer' => 9007199254740993,
172+
'optional' => null,
173+
'unicode' => 'λ',
174+
], $stub->output());
175+
self::assertSame(1, SqliteContentionActivity::$invocations);
176+
self::assertSame($deadlines, [
177+
$run->fresh()
178+
->execution_deadline_at->toJSON(),
179+
$run->fresh()
180+
->run_deadline_at->toJSON(),
181+
]);
182+
self::assertSame($prefix, WorkflowHistoryEvent::query()->where('workflow_run_id', $run->id)
183+
->orderBy('sequence')
184+
->limit(count($prefix))
185+
->get()
186+
->map->getAttributes()
187+
->all());
188+
self::assertSame(0, WorkflowTask::query()->where('workflow_run_id', $run->id)->sum('repair_count'));
189+
self::assertSame(0, DB::table('jobs')->count());
190+
self::assertSame(0, DB::table('failed_jobs')->count());
191+
}
192+
90193
public function testActivityOutcomeWaitsForTheWriterWithinItsOriginalClaim(): void
91194
{
92195
config()->set('logging.default', 'null');
@@ -109,6 +212,7 @@ public function testActivityOutcomeWaitsForTheWriterWithinItsOriginalClaim(): vo
109212
$admissions = 0;
110213
$rollbacks = 0;
111214
$claim = null;
215+
$releaseAt = hrtime(true) + 5_100_000_000;
112216
$connection->beforeExecuting(static function (string $sql) use (
113217
$writer,
114218
$task,
@@ -130,13 +234,16 @@ public function testActivityOutcomeWaitsForTheWriterWithinItsOriginalClaim(): vo
130234
->listen(TransactionRolledBack::class, static function ($event) use (
131235
$writer,
132236
$connection,
133-
&$rollbacks
237+
&$rollbacks,
238+
$releaseAt,
134239
): void {
135240
if ($event->connection === $connection && $writer->inTransaction()) {
136241
self::assertSame(0, $connection->transactionLevel());
137242
self::assertFalse($connection->getPdo()->inTransaction());
138243
$rollbacks++;
139-
$writer->commit();
244+
if (hrtime(true) >= $releaseAt) {
245+
$writer->commit();
246+
}
140247
}
141248
});
142249
$started = hrtime(true);
@@ -149,7 +256,7 @@ public function testActivityOutcomeWaitsForTheWriterWithinItsOriginalClaim(): vo
149256
}
150257
$elapsed = (hrtime(true) - $started) / 1_000_000_000;
151258
self::assertGreaterThanOrEqual(5, $elapsed);
152-
self::assertSame(1, $rollbacks);
259+
self::assertGreaterThanOrEqual(1, $rollbacks);
153260
self::assertSame(1, SqliteContentionActivity::$invocations);
154261
self::assertIsArray($claim);
155262
self::assertSame('leased', $claim['status']);

0 commit comments

Comments
 (0)