Skip to content

Commit 1ff2a82

Browse files
authored
Merge pull request #813 from durable-workflow/release/2.5.25-admission
Release 2.5.25 bounded SQLite task admission
2 parents 62a214a + 82356a8 commit 1ff2a82

9 files changed

Lines changed: 126 additions & 8 deletions

File tree

‎.github/pr-test-selection.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"tests/Unit/V2/PlatformProtocolSpecsTest.php",
1313
"tests/Unit/V2/ReadmeBranchDestinationsTest.php",
1414
"tests/Unit/V2/ScopedCancellationContextTest.php",
15+
"tests/Unit/V2/SqliteTransactionTest.php",
1516
"tests/Unit/V2/StartOptionsTest.php",
1617
"tests/Unit/V2/VersionMarkerWireFormatTest.php",
1718
"tests/Unit/V2/WorkflowFiberRunnerTest.php",

‎.github/queue-contention/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ expected for an unsuccessful control. Completion with repairs or failed
3939
deliveries also fails the clean acceptance check. Do not reinterpret that as a
4040
failed workflow if the result says it completed.
4141

42+
Set `DW_PROBE_REPAIR_OBSERVATIONS=true` for bounded task and native-row metadata
43+
before repair updates, at most eight observations per process. Records can
44+
precede a rolled-back transaction. The final repair count reports committed
45+
effects. These diagnostic reads do not grant claim or execution authority.
46+
4247
`business_invariants_pass` reports exact typed completion, callback/attempt
4348
counts and preserved original timing/history. `clean_acceptance_pass` also
4449
requires zero repairs, failed deliveries and leftover jobs. Both remain visible.

‎.github/queue-contention/runner.php‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,32 @@ protected function defineEnvironment($app): void
199199
$diagnostics = (object) [
200200
'rollback' => null,
201201
'errors' => 0,
202+
'repairs' => 0,
202203
];
203204
$deliveryErrorPath = $directory . '/delivery-errors.jsonl';
205+
$repairObservationPath = $directory . '/repair-observations.jsonl';
206+
if (getenv('DW_PROBE_REPAIR_OBSERVATIONS') === 'true') {
207+
WorkflowTask::updating(static function (WorkflowTask $task) use ($diagnostics, $repairObservationPath): void {
208+
if (! $task->isDirty('repair_count') || ++$diagnostics->repairs > 8) {
209+
return;
210+
}
211+
$reference = $task->queue_delivery_reference;
212+
$row = is_array($reference) && isset($reference['job_id'])
213+
? DB::table('jobs')->useWritePdo()->where('id', $reference['job_id'])
214+
->first(['id', 'reserved_at', 'attempts', 'available_at'])
215+
: null;
216+
file_put_contents($repairObservationPath, json_encode([
217+
'stage' => 'before_update',
218+
'pid' => getmypid(),
219+
'task_id' => $task->id,
220+
'status' => $task->status->value,
221+
'repair_before' => $task->getOriginal('repair_count'),
222+
'repair_after' => $task->repair_count,
223+
'native_row' => $row,
224+
'observed_unix' => time(),
225+
], JSON_THROW_ON_ERROR) . "\n", FILE_APPEND | LOCK_EX);
226+
});
227+
}
204228
$app['events']->listen(Illuminate\Queue\Events\JobProcessing::class, static function () use ($diagnostics): void {
205229
$diagnostics->rollback = null;
206230
});
@@ -330,6 +354,12 @@ protected function defineEnvironment($app): void
330354
'ready_jobs' => DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', time())->count(),
331355
'reserved_jobs' => DB::table('jobs')->whereNotNull('reserved_at')->count(),
332356
'failed_jobs' => DB::table('failed_jobs')->count(),
357+
'repair_observations' => is_file($repairObservationPath)
358+
? array_map(
359+
static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR),
360+
file($repairObservationPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)
361+
)
362+
: [],
333363
'failures' => $failures,
334364
'callbacks' => count($callbacks),
335365
'callback_members' => array_count_values(

‎.github/workflows/php.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ jobs:
335335
run: |
336336
mkdir -p build/test-results
337337
vendor/bin/phpunit tests/Unit/V2/DatabaseQueueRecoveryTest.php \
338+
tests/Unit/V2/EmbeddedSqliteTaskContentionTest.php \
338339
--fail-on-warning --log-junit build/test-results/pr-database-queue-recovery.xml
339340
340341
- name: Resolve bounded pull-request test selection

‎CHANGELOG.md‎

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

3+
## 2.5.25 - 2026-10-16
4+
5+
- Allow additional proven SQLite root rollbacks during task write admission
6+
within the existing retry window, before the claim or execution body starts.
7+
- Preserve explicit attempt limits, ordinary mutation retries, other database
8+
drivers and uncertain commit/rollback handling. Activity callbacks are not
9+
repeated to publish their results.
10+
- Keep original tasks, histories, retry policies and deadlines. Native contention
11+
still propagates when the bounded admission budget is exhausted.
12+
313
## 2.5.24 - 2026-10-16
414

515
- Avoid redundant embedded recovery dispatches while an exact unreserved

‎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.24",
82+
"product-train": "2.5.25",
8383
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
8484
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
8585
},

‎src/V2/Support/SqliteTransaction.php‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ public static function run(Closure $operation, ?int $attempts = null, ?Closure $
4747
}
4848

4949
$deadline = hrtime(true) + 5_000_000_000;
50-
$maximumAttempts = max(1, $attempts ?? 3);
50+
// Before admission, no execution body has run. Keep retrying proven
51+
// rollbacks within the existing window instead of exhausting three
52+
// fast lock refusals. Preserve ordinary mutation and explicit limits.
53+
$maximumAttempts = max(1, $attempts ?? ($admission === null ? 3 : 64));
5154
for ($attempt = 1; ; $attempt++) {
5255
$failedInRoot = false;
5356
$returned = false;
@@ -89,7 +92,7 @@ public static function run(Closure $operation, ?int $attempts = null, ?Closure $
8992
throw $exception;
9093
}
9194

92-
usleep($attempt * 20_000);
95+
usleep($admission === null ? $attempt * 20_000 : min($attempt * 20_000, 100_000));
9396
if (hrtime(true) >= $deadline) {
9497
throw $exception;
9598
}

‎tests/Unit/V2/EmbeddedSqliteTaskContentionTest.php‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ public static function phases(): array
6565
foreach ($phases as $name => [$phase]) {
6666
$cases[$name] = [$phase, false];
6767
$cases[$name . ' with separate application database'] = [$phase, true];
68+
if (! in_array($phase, ['start', 'signal'], true)) {
69+
$cases[$name . ' after three rollbacks'] = [$phase, false, 3];
70+
$cases[$name . ' after three rollbacks with separate application database'] = [$phase, true, 3];
71+
}
6872
}
6973

7074
return $cases;
@@ -177,8 +181,11 @@ static function (QueryExecuted $query) use (
177181
}
178182

179183
#[DataProvider('phases')]
180-
public function testOriginalRunCompletesOnceAfterRealWalWrite(string $phase, bool $separate): void
181-
{
184+
public function testOriginalRunCompletesOnceAfterRealWalWrite(
185+
string $phase,
186+
bool $separate,
187+
int $releaseAfter = 1
188+
): void {
182189
if ($separate) {
183190
config()->set('database.connections.application_sqlite', [
184191
'driver' => 'sqlite',
@@ -199,9 +206,10 @@ public function testOriginalRunCompletesOnceAfterRealWalWrite(string $phase, boo
199206
$armed = $phase === 'start';
200207
$injected = false;
201208
$admissions = 0;
209+
$rollbacks = 0;
202210
$connection->getEventDispatcher()
203-
->listen(TransactionRolledBack::class, static function () use ($writer): void {
204-
if ($writer->inTransaction()) {
211+
->listen(TransactionRolledBack::class, static function () use ($writer, $releaseAfter, &$rollbacks): void {
212+
if ($writer->inTransaction() && ++$rollbacks >= $releaseAfter) {
205213
$writer->commit();
206214
}
207215
});

‎tests/Unit/V2/SqliteTransactionTest.php‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,39 @@ public function testWriteAdmissionRetriesBeforeEnteringBody(): void
415415
$this->assertSame(1, (int) $this->connection->selectOne('SELECT value FROM state WHERE id=1')->value);
416416
}
417417

418+
public function testWriteAdmissionCanRecoverAfterThreeProvenRootRollbacks(): void
419+
{
420+
$writer = $this->writer();
421+
$writer->beginTransaction();
422+
$writer->exec('UPDATE state SET value=1 WHERE id=2');
423+
$rollbacks = 0;
424+
$this->events->listen(TransactionRolledBack::class, function () use ($writer, &$rollbacks): void {
425+
$rollbacks++;
426+
$this->assertSame(0, $this->connection->transactionLevel());
427+
$this->assertFalse($this->connection->getPdo()->inTransaction());
428+
if ($rollbacks === 3) {
429+
$writer->commit();
430+
}
431+
});
432+
$calls = 0;
433+
try {
434+
SqliteTransaction::run(function () use (&$calls): void {
435+
$calls++;
436+
$this->connection->update('UPDATE state SET value=value+1 WHERE id=1');
437+
}, admission: function (): void {
438+
$this->connection->update('UPDATE state SET id=id WHERE id=1');
439+
});
440+
} finally {
441+
if ($writer->inTransaction()) {
442+
$writer->rollBack();
443+
}
444+
}
445+
$this->assertSame(3, $rollbacks);
446+
$this->assertSame(1, $calls);
447+
$this->assertSame(1, (int) $this->connection->selectOne('SELECT value FROM state WHERE id=1')->value);
448+
$this->assertSame(1, (int) $this->connection->selectOne('SELECT value FROM state WHERE id=2')->value);
449+
}
450+
418451
public function testExhaustedAdmissionNeverEntersBody(): void
419452
{
420453
$writer = $this->writer();
@@ -425,7 +458,7 @@ public function testExhaustedAdmissionNeverEntersBody(): void
425458
try {
426459
SqliteTransaction::run(static function () use (&$calls): void {
427460
$calls++;
428-
}, admission: function () use (&$admissions): void {
461+
}, 3, admission: function () use (&$admissions): void {
429462
$admissions++;
430463
$this->connection->update('UPDATE state SET id=id WHERE id=1');
431464
});
@@ -441,6 +474,33 @@ public function testExhaustedAdmissionNeverEntersBody(): void
441474
$this->assertFalse($this->connection->getPdo()->inTransaction());
442475
}
443476

477+
public function testDefaultAdmissionRemainsBoundedWithoutExecutingTheBody(): void
478+
{
479+
$writer = $this->writer();
480+
$writer->beginTransaction();
481+
$writer->exec('UPDATE state SET value=1 WHERE id=2');
482+
$admissions = 0;
483+
$calls = 0;
484+
try {
485+
SqliteTransaction::run(static function () use (&$calls): void {
486+
$calls++;
487+
}, admission: function () use (&$admissions): void {
488+
$admissions++;
489+
$this->connection->update('UPDATE state SET id=id WHERE id=1');
490+
});
491+
$this->fail('Persistent contention must keep its original exception.');
492+
} catch (PDOException $exception) {
493+
$this->assertSame(5, $exception->errorInfo[1] & 255);
494+
} finally {
495+
$writer->rollBack();
496+
}
497+
$this->assertGreaterThan(3, $admissions);
498+
$this->assertLessThanOrEqual(64, $admissions);
499+
$this->assertSame(0, $calls);
500+
$this->assertSame(0, $this->connection->transactionLevel());
501+
$this->assertFalse($this->connection->getPdo()->inTransaction());
502+
}
503+
444504
public function testAdmittedBodyDoesNotRetryItsOwnContention(): void
445505
{
446506
$writer = $this->writer();

0 commit comments

Comments
 (0)