Own the job substrate — a Postgres-backed queue replaces Inngest, and the serverless-shaped stepping goes with it
Production runs its whole job substrate on Inngest's Hobby plan — 5 concurrent executing steps, read off the billing dashboard on 2026-08-23 (MOTIR-3406). Every work-item/transitioned event has four consumers, so one status change occupies 4 of those 5 slots and any cascade over two or more items oversubscribes the entire account. That is the measured cause of the 18-20 s fast-lane lag in docs/decisions/job-lane-occupancy.md §6, and it reproduces at a single user closing one story.
The next Inngest tier is $99/month. This epic takes the third road: run the queue ourselves, on the Postgres and the long-lived Fly machines we already pay for.
Why this is affordable rather than a rewrite
- The seam already exists. Exactly THREE files import the Inngest SDK —
lib/jobs/client.ts,lib/jobs/defineJob.tsandapp/api/inngest/route.ts— and an ESLintno-restricted-importsrule keeps it that way. All 24 job definitions are written against our owndefineJob/sendEvent. - The constraint that shaped the code is gone.
DockerfileendsCMD ["node", "server.js"]: motir-core is a long-lived process on Fly.maxDuration = 300in the serve route was Vercel's ceiling, and the stepped-supervisor architecture exists only to survive it. So this migration deletes machinery rather than porting it.
The two clauses in the title, and who owns each
| clause | owner |
|---|---|
| a Postgres-backed queue replaces Inngest | the engine story, then the three cutover stories, then the retirement story |
| the serverless-shaped stepping goes with it | the supervisor story, which collapses the stepped loops into ordinary while loops |
Scope boundary
IN: motir-core only, and only the job substrate beneath defineJob / sendEvent.
OUT, explicitly:
motir-aiis untouched — it has no Inngest dependency at all (grep -rl inngest motir-ai/srcreturns nothing; itspackage.jsonnames it zero times). This epic is single-repo.- No job's OBSERVABLE behaviour changes. A job that retried five times still retries five times; a debounced refresh still coalesces. Callers do not learn that the engine moved.
- The 4x consumer fan-out stays as it is. Fanning those four consumers into one would cut demand fourfold and is worth doing, but it is a separate argument about our own design and must not be smuggled in here.
- No new infrastructure. If a candidate needs Redis or any other new service, it has failed this epic's premise and the decision subtask must say so.
Acceptance criteria
- No file in
motir-coreimportsinngestorinngest-cli, and neither package is inpackage.json. - All 24 jobs run on the Postgres engine in production, with the
job_runledger, the DLQ and/settings/workspace/jobsworking exactly as before. - The interactive-latency budget in
lib/jobs/latencyBudget.tsis re-measured after cutover and the new figure recorded beside the Inngest baseline it replaces. docs/jobs.mdanddocs/decisions/job-lane-occupancy.mddescribe the substrate that actually runs, with every Inngest-specific claim removed or re-stated.- The monthly Inngest spend is zero and no new paid service replaces it.
Context refs
docs/decisions/job-lane-occupancy.md— the measurement that motivates this, and the 5-step ceilinglib/jobs/defineJob.ts·lib/jobs/client.ts·app/api/inngest/route.ts— the three-file seamlib/jobs/registry.ts·lib/jobs/definitions/— the 24 jobs to movelib/jobs/latencyBudget.ts— the budget this epic is expected to move- Bug MOTIR-3245 and its children — where the ceiling was found