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

(motir-core) Event-level IDEMPOTENCY on the engine — carry `defineJob`'s key through and dedupe on it, so `email.send` still sends once

Done
Description

Implement defineJob's idempotency option on the Postgres engine, so a repeated email.send with the same key produces one delivery rather than two.

What is shipped, and what is missing

Verified on origin/main@b944dab5:

  • lib/jobs/definitions/emailSend.ts declares idempotency: EMAIL_SEND_IDEMPOTENCY, where EMAIL_SEND_IDEMPOTENCY = 'event.data.idempotencyKey'. It is the only job in the tree that declares one.
  • lib/jobs/defineJob.ts forwards idempotency into the Inngest config object — and does not pass it to registerEngineJob. EngineJobDefinition has no such field.
  • sendEvent reads data.idempotencyKey and hands it to dispatchEventToEngine, which writes it to job_event.idempotency_key.
  • prisma/schema.prisma has the column and @@index([idempotencyKey]), with a comment naming this exact use: "Event-level dedup (defineJob's idempotency option, one consumer today: email.send)".
$ git grep -n "idempotencyKey" origin/main -- lib/jobs lib/repositories/jobEventRepository.ts

returns writes and pass-throughs only. Nothing reads the column to skip anything. The schema anticipated the feature; the engine never implemented it.

The shape to build

Carry the key through registration, resolve it at dispatch, and let a UNIQUE CONSTRAINT do the deduplication.

  1. EngineJobDefinition gains idempotency: string | undefined, passed by defineJob from the same option Inngest already receives. One field, at the choke point every job passes through.
  2. A TOTAL resolver. The option's value is an Inngest CEL template, and exactly one form is in use: event.data.<field>. Support that form and throw at registration on anything else — a future job with a richer template must fail loudly rather than silently stop deduplicating. (Rung 2: a lookup keyed off a value must be total over what it can hold; a resolver that returns null for an unrecognised template is the silent arm this rule exists to forbid.)
  3. Dedup by constraint, not by check-then-insert. The enqueue is a read-derived write: two concurrent identical sends would both read "no prior row" and both insert. So denormalise the resolved key onto job_queue and add a partial unique index on (job_id, idempotency_key) WHERE idempotency_key IS NOT NULL, then treat P2002 as "already enqueued" — which is exactly the pattern dispatchEventToEngine already applies to its (event_id, job_id) constraint, with the reasoning in its own header ("not a check-then-insert: a check would be a read-derived write with a race in the middle"). Reuse that shape rather than inventing a second one.

⚠️ Prisma cannot express a partial unique index (@@unique takes no WHERE), so the index is raw SQL inside the migration. Run prisma migrate diff afterwards and confirm the schema and the database agree — a hand-written index that the schema does not describe is a standing drift trap in this repo, and a later migrate dev will offer to "fix" it.

⚠️ This makes engine dedup STRICTER than Inngest's, and that must be recorded rather than absorbed

Inngest dedupes same-key events inside a window; an unbounded unique constraint dedupes forever. Forever is the better behaviour for the keys actually in use — a password-reset token and an invite token should each produce one email, not one per window — and it is race-free where a windowed query is not.

But MOTIR-3413's boundary says "No job's OBSERVABLE behaviour changes", and this is one. Name it in the PR body and carry it into docs/jobs.md via the docs card — a deliberate, argued divergence recorded on the day it is made, not discovered later as a discrepancy. If a window is wanted instead, that is a decision, and it needs the window's number and where the number came from.

The DLQ replay path is unaffected either way: lib/jobs/dlq.ts already rewrites the key to <key>:replay:<dlqId> so a replay is not dedup-dropped, and that reshaping happens on the Inngest arm — check the engine arm above it does the equivalent, or make it do so.

Scope boundary

ENDS at: engine-side dedup working for any job declaring idempotency, with tests. Does NOT change email.send's handler, its template, or any caller of sendEvent('email.send', …). Does NOT implement debounce or concurrency on the engine — no job in this story's set declares either (tests/jobs/fast-lane-latency-budget.test.ts asserts the fast lane carries neither), and the one debounced job, system.code-graph-refresh, is MOTIR-3417's.

Acceptance criteria

  • A job declaring idempotency and routed to the engine enqueues ONE job_queue row for two events carrying the same resolved key, and two rows for two different keys — asserted against real Postgres, not a mock.
  • The duplicate arrives CONCURRENTLY in at least one test — two dispatches racing on a warm pool — and the outcome is one row plus a swallowed P2002, never two rows and never a thrown error reaching the caller.
  • A job declaring NO idempotency is unaffected: two identical events produce two rows.
  • The resolver THROWS on a template it does not understand, asserted by a test that registers a job with one.
  • prisma migrate diff reports no drift between prisma/schema.prisma and the migrated database after the raw-SQL index lands.
  • email.send's Inngest behaviour is byte-identical — the config object defineJob builds for it is unchanged, asserted off fn.opts.

Context refs

  • lib/jobs/definitions/emailSend.tsEMAIL_SEND_IDEMPOTENCY, the only declaration
  • lib/jobs/defineJob.ts — where the option is forwarded to Inngest and dropped for the engine
  • lib/jobs/engine/registry.tsEngineJobDefinition, the field to add
  • lib/jobs/engine/dispatcher.ts — the enqueue, and the P2002-as-success pattern to reuse
  • lib/jobs/dlq.ts — the replay key reshaping, on both arms
  • prisma/schema.prismaJobEvent.idempotencyKey and JobQueueRun, and the comment naming this feature
  • tests/jobs/email-send.test.ts — the existing coverage this must not break