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-3413

Own the job substrate — a Postgres-backed queue replaces Inngest, and the serverless-shaped stepping goes with it

Done
Description

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.ts and app/api/inngest/route.ts — and an ESLint no-restricted-imports rule keeps it that way. All 24 job definitions are written against our own defineJob / sendEvent.
  • The constraint that shaped the code is gone. Dockerfile ends CMD ["node", "server.js"]: motir-core is a long-lived process on Fly. maxDuration = 300 in 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

clauseowner
a Postgres-backed queue replaces Inngestthe engine story, then the three cutover stories, then the retirement story
the serverless-shaped stepping goes with itthe 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-ai is untouched — it has no Inngest dependency at all (grep -rl inngest motir-ai/src returns nothing; its package.json names 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-core imports inngest or inngest-cli, and neither package is in package.json.
  • All 24 jobs run on the Postgres engine in production, with the job_run ledger, the DLQ and /settings/workspace/jobs working exactly as before.
  • The interactive-latency budget in lib/jobs/latencyBudget.ts is re-measured after cutover and the new figure recorded beside the Inngest baseline it replaces.
  • docs/jobs.md and docs/decisions/job-lane-occupancy.md describe 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 ceiling
  • lib/jobs/defineJob.ts · lib/jobs/client.ts · app/api/inngest/route.ts — the three-file seam
  • lib/jobs/registry.ts · lib/jobs/definitions/ — the 24 jobs to move
  • lib/jobs/latencyBudget.ts — the budget this epic is expected to move
  • Bug MOTIR-3245 and its children — where the ceiling was found
Child work items
Status
Done
Type
Epic
Children
30