Skip to content

Database schema

Five bookkeeping tables. None of them hold your data — they record what a backfill did and how far it got.

backfill_runs

One row per run. This is the table that makes a backfill resumable.

ColumnTypeDescription
idbigint
backfillstringThe short name, e.g. user-slugs
tenantstring, nullWhich tenant this run belongs to
backfill_classstringFully qualified class name
statusstringpending, running, paused, completed, failed, cancelled, interrupted
cursorstring, nullLast committed key. String so integers, UUIDs and ULIDs all round-trip
key_namestringColumn paginated over
total_estimatebigint, nullRow count at start; null with --no-count
processed_countbigintRows successfully processed
failed_countbigintRows that threw
skipped_countbigintRows skipped by the ledger
batch_countintBatches committed
batch_sizeintRows per batch as configured
sleep_msintPause between batches as configured
dry_runboolReserved; dry runs do not create run records
started_bystring, nullcli:user, queue, dashboard:…, operator:…
heartbeat_attimestampUpdated every batch. A cold one means a crash
started_attimestamp, null
finished_attimestamp, nullNull while paused — it may yet continue
errortext, nullException message for a failed run
metajson, nullstop_reason, stop_code, parameters, parameter_summary

The cursor is stored as a string deliberately: an integer column would lose UUID and ULID keys.

meta

KeyDescription
stop_reasonHuman-readable sentence explaining why it stopped
stop_codeMachine-readable StopReason
parametersValidated parameter values
parameter_summaryReadable summary for the audit trail

backfill_run_errors

One row per failed record.

ColumnTypeDescription
idbigint
run_idbigint
record_idstring, nullThe key of the row that failed
exception_classstring
messagetextTruncated at 60,000 characters
tracelongtext, null
attemptsintIncremented by backfill:retry-failed
resolved_attimestamp, nullSet when a retry succeeds

Written inside the batch transaction, so error records and the cursor commit together. This is why per-row savepoints matter on PostgreSQL — without them the insert is rejected along with everything else.

backfill_locks

The run lock. A row here means someone is running that backfill.

ColumnTypeDescription
idbigint
backfillstring, uniqueLock key — name or name:tenant
run_idbigint, nullAttached once the run row exists
ownerstring, nullhostname:pid
acquired_attimestamp, null
heartbeat_attimestamp, nullRefreshed every batch

The unique index is the lock. Acquiring is an insertOrIgnore, which gives identical mutual exclusion on MySQL, PostgreSQL and SQLite — unlike a partial unique index, which MySQL does not have, or a cache key, which can be flushed.

A lock whose heartbeat is older than stale_after was abandoned by a killed process and is taken over automatically.

backfill_run_batches

Per-batch audit trail. Off by default; enable with record_batches.

ColumnTypeDescription
idbigint
run_idbigint
from_id / to_idstring, nullKey range covered
countintRows in the batch
failedintRows that threw
duration_msintHow long it took
attemptsint1, or more after a transient retry
created_attimestamp, null

Powers the dashboard sparkline and answers "where did it slow down" after the fact.

backfill_ledger

Used only by ledger mode.

ColumnTypeDescription
idbigint
backfillstring
record_idstringUnique together with backfill
run_idbigint, null
claimed_attimestamp, nullSet before process() runs
processed_attimestamp, nullSet after it succeeds

A row with claimed_at but no processed_at is one nobody can be sure about — the run stopped part way through it. Those are never retried automatically.

Querying it yourself

php
use Kstmostofa\Backfill\Models\BackfillRun;
use Kstmostofa\Backfill\Models\BackfillRunError;
use Kstmostofa\Backfill\Models\BackfillRunBatch;
use Kstmostofa\Backfill\Models\BackfillLedgerEntry;

$run = BackfillRun::where('backfill', 'user-slugs')->latest('id')->first();

$run->progressPercent();        // 25.4
$run->throughputPerSecond();    // 1240.0
$run->isStale();                // heartbeat gone cold?
$run->status->isResumable();

$run->errors()->whereNull('resolved_at')->get();
BackfillLedgerEntry::unconfirmed()->where('backfill', 'customer-emails')->get();

Released under the MIT Licence.