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

(motir-core) Decide how the EMIT PATH learns the subscriber set — the engine registry is empty on a web request

Done
Description

Decide how the process that EMITS an event learns which jobs subscribe to it, and record the decision as an amendment to docs/decisions/job-queue-foundation.md.

The question

dispatchEventToEngine resolves its fan-out with engineSubscribers(name), which filters a module-level Map in lib/jobs/engine/registry.ts. That Map is filled by registerEngineJob, called from defineJobso it holds only the jobs whose definition MODULE has been evaluated in this process. The file says so itself:

COMPLETENESS DEPENDS ON IMPORT, identically to schedules.ts: the table holds only jobs whose definition module has been evaluated. lib/jobs/registry.ts imports all of them, so any consumer MUST import that module first.

⚠️ AMENDED 2026-08-25, in this card's own run (branch parent/MOTIR-3415-event-jobs-cutover)

The grep below returns NINETEEN files at the ref this card itself names, not three. Re-measured at b944dab5 and again at 18d60791; both return 19, so this is an enumeration that was WRONG WHEN WRITTEN, not drift since. The sixteen unlisted extras are all under tests/, and git log --diff-filter=A dates every one of them before this card was created.

The CONCLUSION is unaffected and stands. Exactly two of the nineteen are non-test — app/api/inngest/route.ts and scripts/worker.ts — and those two are the load-bearing fact: no module on any production emit path imports the registry. The extras make the “why nothing caught it” argument STRONGER rather than weaker — seventeen suites evaluate the definition modules in their own process, so a great deal of the job suite's greenness is measured on a module graph that production never has.

docs/decisions/job-queue-foundation.md §11 records the correction in full. Planning bug filed under MOTIR-1465.

Verified on origin/main@b944dab5:

$ git grep -l "@/lib/jobs/registry'" origin/main -- '*.ts' '*.tsx'
  app/api/inngest/route.ts
  scripts/worker.ts
  tests/jobs/engine-dispatcher.test.ts

lib/jobs/sendEvent.ts imports ./client, ./types and ./engine/dispatcher; the dispatcher imports ./engine/registry (the empty Map), and nothing in that graph reaches a definition module. So on a Next.js request path engineSubscribers returns [], no job_queue row is written, hasInngestSubscribers returns its safe default true, inngest.send() fires, and — for a job whose id IS in the routing set — defineJob's handler returns { skipped: 'routed-to-postgres-engine' }. The event runs on neither lane.

Why nothing caught it: the dispatcher test carries import '@/lib/jobs/registry' at line 24, and the E2E lane's inngest-cli dev -u http://localhost:PORT/api/inngest syncs the serve route at lane start, which evaluates the registry inside that same Next server process. Both make the registry complete by a route no production request takes. In production the serve route is evaluated only on a machine Inngest has synced against, and fly.toml sets min_machines_running = 2.

The options

(a) Side-effect import. Add import '@/lib/jobs/registry'; to sendEvent.ts or the dispatcher. One line. Two costs to price rather than assume: every server bundle that emits an event pulls all 24 definition modules and their transitive service imports; and the definitions import services (lib/jobs/services.ts) which import sendEvent, so this closes an import CYCLE at the seam. Both are measurable — measure them, do not argue about them.

(b) A data-only subscriber MANIFEST. Split registration so the emit path needs only { id, trigger, cron, maxAttempts } and never the handler. The manifest module imports no services, so importing it eagerly from sendEvent is cheap and acyclic; the worker keeps importing the full registry for the handlers it must execute.

(c) A database-backed subscriber table, written at deploy or boot and read by the dispatcher. Removes the import question entirely, at the cost of a read on the emit path and a table that can go stale against the deployed code — the exact staleness cutover.ts refused an env-var cache to avoid.

The recommendation — (b), unless the measurement in step 2 contradicts it

Rung 2, read on origin/main@b944dab5: the emit path already needs no handler. dispatchEventToEngine reads only sub.id and sub.maxAttempts; hasInngestSubscribers reads only id and trigger. EngineJobDefinition.handler is the single field that drags the service graph in, and it is the one field neither emit-path caller touches. So (b) is not a new abstraction — it is the split the existing call sites already imply.

Rung 1, the mirror: Inngest resolves an event's subscribers server-side, from a function manifest synced at deploy. The emitting process sends a name and a payload and knows nothing about who consumes it. A manifest is the mirror product's own shape, not an invention.

(c) is rejected on the same reasoning lib/jobs/engine/cutover.ts already records for the routing set: a cached or replicated view of the deployed code's shape can go stale, and stale here means a job that runs on both engines or on neither.

Scope boundary

ENDS at: the amendment merged in docs/decisions/job-queue-foundation.md, with the measurement that settled it. Writes no lib/ code — the implementation is the reachability card.

Also decides for lib/jobs/schedules.ts, which carries the identical import caveat and is MOTIR-3416's problem in the same shape. Say so in the amendment so the scheduled story inherits the answer instead of re-deriving it.

Acceptance criteria

  • The amendment is a NEW section in docs/decisions/job-queue-foundation.md, numbered after the last section present when the card runs — grep -nE '^## §' docs/decisions/job-queue-foundation.md first and take the next number, because a sibling PR may have added one. At authoring time the last was ## §10 — The risk this decision accepts, named.
  • It states the defect with the evidence: the two importers of lib/jobs/registry.ts at a named ref, and why the dispatcher test and the E2E lane are both green.
  • It records a MEASUREMENT for option (a) rather than an argument about it: build the app with the side-effect import present and absent, and quote the two figures the build reports for the route group that emits. If (a) is cheap and acyclic, (a) wins on simplicity and the amendment says so.
  • It states, per option, what happens to lib/jobs/schedules.ts.
  • It names the chosen option, the rejected ones with their reason, and the risk the choice accepts.
  • No file under lib/ or tests/ is modified by this card.

Context refs

  • lib/jobs/engine/registry.ts — the Map, and its own header on import-completeness
  • lib/jobs/engine/dispatcher.tsengineSubscribers / hasInngestSubscribers, the two emit-path readers
  • lib/jobs/sendEvent.ts — the emit path, and its import list
  • scripts/worker.ts — the side-effect import, with the comment explaining why it looks unused
  • tests/jobs/engine-dispatcher.test.ts — line 24, why the suite is green
  • docs/decisions/job-queue-foundation.md — §4/§8, which name the dispatcher and the registry without deciding this