Skip to content

measured · · 17 min

Anatomy of a migration engine that survives being killed

The complete architecture of a system that moved 483,631 records through a metered third-party API in twelve hours: phase barriers, four striped cursors, fenced claims, 25-row savepoint windows sized against Postgres's subtransaction cache, a write-once ledger whose ON CONFLICT clause decides ownership, burst leases taken before the network call rather than during it, and where each of six failure classes is caught. Six diagrams, the SQL, and the constants with the reasoning that set them.

Kill the worker at any second of a twelve-hour import and the answer has to be the same: restart it, lose at most twenty-five rows of redone work, duplicate nothing.

That single requirement produces almost every decision below. This is the whole architecture — seven layers, the SQL that enforces the important ones, and the six failure classes with the level each is caught at.

The system it describes moved 483,631 records in 12 h 29 m against an API that prices every request, with the database at 9–15% CPU throughout and zero failed tasks.

1. The shape of the problem

Half a million rows is not much data. It is a lot of time, spent talking to a third party that throttles you, times out, and meters what you spend.

Three properties fall out of that:

Nothing may run in one process. A twelve-hour loop is a twelve-hour window in which a deploy, an OOM or a rescheduled container destroys all progress.

Nothing may be held across a network call. Not a lock, not a transaction. The provider decides how long it takes to answer, and any lock you hold is a lock they are holding.

Every unit of work must be re-runnable. Not "idempotent if you are careful" — re-runnable by construction, because it will be re-run.

2. Topology: where the work actually executes

Two containers on one host. The web container runs Puma and executes no jobs at all — its queue configuration is :external, so a request thread can never be blocked behind a migration page. The jobs container is one process, one GVL, and inside it a scheduler per queue, each with its own thread pool.

Where migration work executesweb containerPuma · 2 workers × 3 threadsqueues: :external — executes 0 jobsjobs container — one process, one GVLnotifier LISTENpoller 15 scron manager9 schedulers · 24 threads totalmigrations lane — its OWN pool, 4 threadsa lane is a private pool, not a share of a global onePostgresapp + queue + cacheone databasemetered APIpriced per requestqueue rows,app rows, cacheonly themigration lane

One GVL means the four migration threads interleave rather than run in parallel — which is fine, because they spend their lives waiting on a socket. The isolation that matters is that no request thread can be starved by them.

3. Phases: a barrier, not a sequence

A run is a directed graph of phases. Each is cut into tasks, and a phase cannot start until every task in the phase before it has succeeded.

That fence is what lets the deal writer assume its contact already exists, which is what lets every writer be a straight-line function instead of a retry loop that waits for its parents.

Phase graph and barriersworkspacecompaniescontactsdealsleadsactivitiesnotesmail discoverynote commentsemails1 job50 · 23,227319 · 157,991158 · 77,2356 · 1,17126 · 2,944118 · 57,316155 jobs87 · 47,2404,046 · 116,507the last two do not page a collection — they build their work from rows earlier phases wrote

Mail discovery walks the deal list once reading a per-deal message count and writes one task per deal that has mail — 4,046 of them. That inverts the pattern in section 4 and is why the email phase is 81% of all jobs.

The barrier is advanced by a fenced update, not by whoever happens to finish last. A task that thinks it is closing a phase must win a conditional write to do so, which is what stops two finishing tasks both opening the next phase.

4. Paging: cursor in a row, not on a stack

A page is 500 rows. The engine does not sit in a loop holding the collection's progress in memory — each page is a row in a table carrying its own cursor.

terminal
body = get(path, params.merge(start: start, limit: PAGE_SIZE))   # 500
pagination = body.dig("additional_data", "pagination") || {}
more  = pagination.key?("more_items_in_collection") ?
        !!pagination["more_items_in_collection"] : data.length >= PAGE_SIZE
start = next_start && next_start > start ? next_start : start + PAGE_SIZE

Three of those lines are scar tissue. One collection omits the pagination block entirely, so a full page has to mean "probably more" and costs one empty trailing fetch. Another ignores start and returns the whole set on every call — caught by noticing a page opens with the previous page's first id. A stale cursor falls back to offset arithmetic. MAX_PAGES backstops the rest.

Four stripes walk one collection at once, each owning every fourth page, so they never contend for the same offset.

Four stripes over one collection157,991 contacts · 316 pages of 5000500100015002000250030003500stripe 0stripe 1stripe 2stripe 3page 1page 5page 2page 6page 3page 7page 4page 8each stripe enqueues its own next page BEFORE it reports

Chaining before reporting matters: a page that fails must not truncate the walk behind it. The next page is already queued by the time the current one decides whether it succeeded.

A task is claimed by a fenced update — a conditional write that only one worker can win — so two workers can never hold the same page.

5. Windows: why 25, and why it is not a tuning knob

500 rows cannot be one transaction. Postgres caches at most 64 subtransaction ids per top-level transaction (PGPROC_MAX_CACHED_SUBXIDS, compiled in). Past 64 the snapshot is marked suboverflowed and visibility checks fall back to SLRU lookups on disk.

This path opens two savepoints per row: one so a raising row cannot poison the window, and one inside the ledger write, which must be its own or raise ActiveRecord::Rollback is swallowed by the outer block and commits a record whose claim was refused.

terminal
64 cached subxids ÷ 2 savepoints per row = 32 rows of headroom
WINDOW = 25                                 ← the sibling importer uses 50
                                              because it opens one per row
One window: savepoints and a checkpoint that commits with the writesBEGINSAVEPOINTrow 1SAVEPOINTrow 2SAVEPOINTrow 3· · ·SAVEPOINTrow 25checkpoint!(offset: +25, stats:) — inside the transactionCOMMITno instant exists where rows are durable but the offset claiming them is notso a crash resumes mid-page: it redoes at most 25 rows, never the page

Scale by running more windows, never bigger ones. Widening the window trades a bounded redo cost for an unbounded visibility cost.

6. The ledger: one statement decides ownership

Every write goes through a unique index on (tenant, source, kind, source_id), and one statement resolves the claim:

terminal
INSERT INTO migration_records
  (tenant_id, source, kind, source_id, record_type, record_id, action, ...)
VALUES (...)
ON CONFLICT (tenant_id, source, kind, source_id) DO UPDATE SET
  record_type = EXCLUDED.record_type,
  record_id   = EXCLUDED.record_id,
  action      = EXCLUDED.action,
  reason      = EXCLUDED.reason
WHERE migration_records.action = 'skipped'
RETURNING id

RETURNING yields a row for a fresh insert or a takeover, and nothing when the slot is genuinely owned. Three outcomes from one round trip:

Three outcomes of one claimclaim(source_id)slot empty → insertthis run owns the rowholds a skip → take overa skip is a placeholdercreated / matched → refuseRETURNING is empty; do nothing

The asymmetry is deliberate in both directions. Exclusivity for real writes is what stops two overlapping runs creating one record twice. The skip exception is what stops a row skipped for want of a parent from being condemned by the run that skipped it.

The practical consequence: a re-run needs to know nothing about previous runs. 451,772 already-written rows are each refused in one statement.

7. The rate limiter: lease before the call, not during it

The provider prices requests rather than counting them — a single-record read costs 2 tokens, a list costs 20 — against a daily allowance of roughly 30,000 × plan multiplier × seats. There is also a short burst window measured in requests per two seconds.

Two rules make this survivable.

Take the burst lease on the job thread, before fanning out. Email bodies are fetched four at a time on threads that hold no database connection. Those threads must not each take a row lock on the shared budget row, so the job thread reserves the slots first and the fetch threads spend them:

terminal
def reserve_slots!(count) = count.times { await_slot! }

The waiting and the lock happen once, on one thread, before any of the concurrency starts.

Spend 90% of the window, not 100%. Our count and the provider's disagree by whatever is in flight plus clock skew on the boundary, and the disagreement always leans toward a rejection. Giving up a tenth of the throughput removes the rejections that were costing far more than a tenth in released tasks and re-fetched pages.

8. Six failure classes, six containments

This is the part that decides whether the previous seven sections are architecture or decoration.

Failure classes and their containment levelfailurecaught atrecoverya malformed rowits own SAVEPOINTother 24 commit · no ledger row · a later run imports itRecordInvalid, encoding, overflowa lock conflictthe TASK, re-raisedretry keeps cursor + offset · redoes one windowPostgres aborts the whole transactiona transient API errorbackoff queue8 attempts, polynomial · then discarded to the reaper429 burst, 5xx, socket timeoutthe allowance is spentthe RUN parksprobe once per interval until the provider relentsdaily budget exhaustedthe worker diesthe reaper, 5-min cronstale claim released · task re-enters as pendingdeploy, OOM, killed containerevery row on a page failedTotalFailuretask fails · run is partial · cannot report successattempted > 0 and failed == attempted

The lock conflict is the one that looks misplaced and is not. Postgres aborts the entire transaction when it picks a deadlock victim, so ROLLBACK TO SAVEPOINT cannot contain it and every later row in the window would fail too. It has to belong to the task.

Two of those rows were bought expensively. An earlier version counted a lock conflict as a row error: the run reported success with 10 of 3,000 deals silently missing. A version that retried it in place lost 1,002, because the retry's savepoint ran inside an already-aborted transaction.

9. What it did in production

terminal
483,631 records · 4,966 jobs · 12 h 29 m
0 failed tasks · 5 rows not imported
CPU 9–15% · connections 21–31 · storage 0.6 GB

Throughput was not flat, and the shape is the design working:

terminal
midday      215 emails/min     nothing contending
mid-run      75 emails/min     201 jobs waiting in backoff
evening     439 emails/min     backoff cleared itself

Nobody intervened at any point in that curve. The engine traded speed for compliance while the provider pushed back and took it again when it stopped.

It ended by stopping itself: the allowance hit the reserve floor, the run parked, three log lines were written, the queue drained to 3 jobs and the database went idle. One phase was unfinished, with 44,092 of 57,316 parents already marked — so a resume asks about the remaining 13,224 and nothing else.

10. The constants, and what set them

ConstantValueSet by
rows per API call500the provider's maximum
rows per transaction2564 cached subxids ÷ 2 savepoints per row
stripes per collection4concurrency without offset contention
tasks in flight8a planner that tops up the deficit
bodies fetched in parallel4under the lowest burst tier even on a wide lane
burst spend90%our count and theirs disagree by what is in flight
retry attempts8polynomial backoff reaches ~1 h, enough to ride out an incident
stale claim released after30 minlong enough that re-checking is not itself load

None of those is a preference. Each is the largest value that keeps a specific failure bounded, and the reason is worth more than the number — because when the provider changes, the number moves and the reasoning does not.

Working through something like this? I help teams ship AI and cloud systems that hold up, and cost what they should.