The Reconciler Pattern: When a Queued Job Simply Never Runs
The bug that leaves no trace
Records stuck in pending. No error, no failed job, no log line.
The shape of the code:
$status = SyncStatus::create([...]); // sync_status = 'pending'
ProcessSyncStatus::dispatch($status->id);
One dispatch, at creation. Fine 99% of the time.
The 1%:
| What happened | Where the job went | What you see |
|---|---|---|
queue:clear during an incident |
Payload deleted | Stuck pending, no error |
| No worker running at dispatch | Flushed | Stuck pending, no error |
| Redeploy dropped the connection mid-enqueue | Never enqueued | Stuck pending, no error |
| Job ran and threw | failed_jobs |
An error you can act on |
Only the last row is visible. The first three are invisible failures - dashboards green, data quietly rotting.
The bad mental model
"the queue is durable, so a dispatched job will eventually run."
Your database row is the durable thing. The queue is a delivery hint.
Retry โ reconcile
| Retry | Reconcile | |
|---|---|---|
| Triggered by | A job that ran and failed | A state that looks wrong |
| Source of truth | The queue / failed_jobs |
The database |
| Catches lost dispatches? | No | Yes |
Retry is a queue-level mechanism. Reconciliation is a domain-level one. You need both.
The reconciler
Periodically ask the database "does anything look stuck?", and fix it.
every 15 min
|
v
+----------------------------+
| sweep pending rows |
| updated_at < now()-15m | <- stale
| AND claimed_at IS NULL | <- never picked up by a worker
+----------------------------+
|
| touch(row) <- so the next tick won't sweep it again
v
re-dispatch ProcessSyncStatus
Two predicates carry the weight:
- Stale:
updated_atolder than a threshold - fresh pending rows are just working, give the queue time. - Unclaimed: the job stamps the row the moment a worker picks it up, so a pending-and-claimed row is in flight right now; leave it alone.
Pending and unclaimed after 15 minutes was never delivered.
final class ReconcilePendingSync extends Command
{
protected $signature = 'sync:reconcile-pending
{--minutes=15 : Only sweep rows untouched this long}
{--limit=200 : Cap re-dispatches per run}
{--dry-run}';
public function handle(): int
{
$rows = SyncStatus::query()
->where('sync_status', 'pending')
->whereNull('claimed_at')
->where('updated_at', '<', now()->subMinutes((int) $this->option('minutes')))
->limit((int) $this->option('limit'))
->get();
foreach ($rows as $row) {
if ($this->option('dry-run')) {
continue;
}
// ...report only
$row->touch(); // don't sweep again next tick
ProcessSyncStatus::dispatch($row->id);
Log::info('reconciler: re-dispatched', ['id' => $row->id]);
}
return self::SUCCESS;
}
}
Three things that are easy to get wrong
touch()before dispatch. Otherwise the next tick sees the same stale row and dispatches it again - a job multiplier, not a reconciler.--limit. If something breaks for a day, the first sweep shouldn't dump 40,000 jobs into Redis.- Idempotent job. Reconciliation will occasionally re-run something that was fine. That must be a no-op: claim the row at the top of
handle(), make the work an upsert.
Tests
Queue::fake() plus a backdated updated_at covers the cases that matter:
function agePendingRow(SyncStatus $row, int $minutes): void
{
// Bypass Eloquent timestamps to backdate updated_at.
DB::table('sync_status')
->where('id', $row->id)
->update(['updated_at' => now()->subMinutes($minutes)]);
}
it('re-dispatches a stale unclaimed pending row', function () {
Queue::fake();
$stale = SyncStatus::factory()->create([
'sync_status' => 'pending',
'claimed_at' => null,
]);
agePendingRow($stale, 60);
$this->artisan('sync:reconcile-pending', ['--minutes' => 15])
->assertSuccessful();
Queue::assertPushed(ProcessSyncStatus::class, 1);
});
it('skips a pending row already claimed by a worker', function () {
Queue::fake();
$claimed = SyncStatus::factory()->create([
'sync_status' => 'pending',
'claimed_at' => now(),
]);
agePendingRow($claimed, 60);
$this->artisan('sync:reconcile-pending', ['--minutes' => 15])
->assertSuccessful();
Queue::assertNotPushed(ProcessSyncStatus::class);
});
Plus a third: a fresh pending row must be left alone. Anyone can write a sweeper that re-dispatches everything - the value is in what it refuses to touch.
Takeaway
If a database row implies "a job should be running for this", something needs to check that claim on a schedule. Dispatch-on-create is a happy-path optimisation, not a guarantee.
The rule I'm keeping: the queue delivers, the database decides. Any state that can only be advanced by a queued job needs a reconciler behind it - otherwise "eventually consistent" really means "usually consistent, and silent when it isn't."
Comments
No comments yet. Start the discussion.