MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3414

The Postgres job engine — tables, a worker process, durable steps, and a per-job cutover switch

Done
Description

⚠️ AMENDMENT — the step.run ENUMERATION in this card is WRONG (motir run MOTIR-3414, 2026-08-23)

Re-measured before the first line of work, on the ref rather than on a working tree, per the run-time rule that a card counting a population is re-measured against the ref it names (or origin/main when it names none). Branch base: origin/main@165f1485.

$ git grep -c 'step\.run(' origin/main -- '*.ts' '*.tsx'
  60 occurrences across 27 files
$ git grep -c 'step\.run(' origin/main -- '*.ts' '*.tsx' ':!tests/*'
  58 occurrences across 26 files

"84 call sites across 37 files" is in fact 58 across 26 (60 across 27 if the one test file is counted). Read every "84" below as 58, and every "37 files" as 26.

The second limb is falsified outright, and it is the one that was load-bearing. The story's explanationMd argued the shim earns its cost because the call sites live "including services well outside lib/jobs/". They do not:

$ git grep -l 'step\.run(' origin/main -- '*.ts' '*.tsx' ':!tests/*'
  25 of 26 files are under lib/jobs/ ;
  the 26th is scripts/plan-seed/data/story-1.6.ts — seed FIXTURE DATA, not a service

No service file outside lib/jobs/ calls step.run. The blast radius of rewriting the call sites is lib/jobs/ and nothing else — which is where the seam already is.

What changes and what does not. The DELIVERABLE is untouched and the CONCLUSION stands: 58 call sites are still 58 that should not be rewritten, and a shim that keeps the existing job definitions compiling unchanged is still the right shape. What weakens is the COST argument — the shim is bought for compatibility, risk and reversibility, not for a blast radius that reaches outside the jobs layer. Recorded rather than quietly deleted, because an amendment with evidence and a silently-dropped clause look identical in the diff and are opposite in kind.

Where 37 probably came from: lib/jobs/registry.ts exports 37 job functions across 24 definition files. Two real populations, correctly observed, fused into a sentence about a third.

Planning bug: MOTIR-3428. Nothing else in this card is changed by the amendment — no deliverable, edge, repo pin, estimate or acceptance criterion.


The foundation every other story in this epic fans off. It builds the Postgres-backed job engine behind the existing defineJob / sendEvent seam and proves it on ONE pilot job in production — it does not migrate the other 23.

What it delivers

  1. Three tablesjob_event (the log a dispatcher fans out from), job_queue (one row per run, with run_at / attempts / state), job_step (memoized step results keyed by (run_id, step_id)).
  2. A worker, in its OWN Fly process group rather than inside the web process, so job load cannot contend with request serving.
  3. The step shim — the piece that makes this migration affordable. step.run(id, fn) becomes look up (run_id, id); if present return it, else execute, persist, return, which is the whole of the Inngest step contract our code actually uses. All 84 step.run call sites across 37 files change by zero characters.
  4. A per-job cutover switch, so sendEvent routes each job id to either engine and the two run side by side for the length of the migration.
  5. Retry, onFailure and DLQ parity — the job_run ledger and /settings/workspace/jobs keep working with no change visible to an operator.

Scope boundary

This story ENDS at: one pilot job running on the new engine in production, with the ledger, the DLQ and the operator dashboard behaving exactly as they do today.

It HANDS OFF: the event-triggered jobs to the event cutover story, the 13 scheduled jobs to the scheduled story, the container supervisors and the collapse of their stepped shape to the supervisor story, and every deletion to the retirement story. Nothing in this story removes any Inngest code — both engines are live when it lands, which is exactly what makes the later cutovers reversible one job at a time.

Verification recipe

Open /settings/workspace/jobs. Trigger the pilot job. See its run row appear, succeed, and carry its output — indistinguishable from an Inngest-backed run. Force it to fail; see it retry, exhaust, and land in the DLQ tab. Press Replay; see it succeed.

A note on what this story is NOT

It is not a general-purpose durable-execution engine. It implements exactly the contract our 24 jobs use — memoized steps, durable sleep, retries, cron, one debounce, one idempotency key — and deliberately nothing else. waitForEvent is not implemented, because it has zero real call sites: the single grep hit in the tree is a comment quoting Inngest's documentation.

Context refs

  • lib/jobs/defineJob.ts — the wrapper whose signature must not change
  • lib/jobs/sendEvent.ts · lib/jobs/registry.ts — the emit side and the function list
  • lib/jobs/dlq.ts · lib/services/jobRunsService.ts · app/(authed)/settings/workspace/jobs/page.tsx — the ledger, the DLQ and the operator surface that must keep working
  • fly.toml — where the worker process group is declared
  • docs/jobs.md — the substrate documentation this story amends