(motir-core) Event-level IDEMPOTENCY on the engine — carry `defineJob`'s key through and dedupe on it, so `email.send` still sends once
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.tsdeclaresidempotency: EMAIL_SEND_IDEMPOTENCY, whereEMAIL_SEND_IDEMPOTENCY = 'event.data.idempotencyKey'. It is the only job in the tree that declares one.lib/jobs/defineJob.tsforwardsidempotencyinto the Inngestconfigobject — and does not pass it toregisterEngineJob.EngineJobDefinitionhas no such field.sendEventreadsdata.idempotencyKeyand hands it todispatchEventToEngine, which writes it tojob_event.idempotency_key.prisma/schema.prismahas the column and@@index([idempotencyKey]), with a comment naming this exact use: "Event-level dedup (defineJob'sidempotencyoption, 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.
EngineJobDefinitiongainsidempotency: string | undefined, passed bydefineJobfrom the same option Inngest already receives. One field, at the choke point every job passes through.- 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 returnsnullfor an unrecognised template is the silent arm this rule exists to forbid.) - 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_queueand add a partial unique index on(job_id, idempotency_key) WHERE idempotency_key IS NOT NULL, then treatP2002as "already enqueued" — which is exactly the patterndispatchEventToEnginealready 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
idempotencyand routed to the engine enqueues ONEjob_queuerow 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
idempotencyis 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 diffreports no drift betweenprisma/schema.prismaand the migrated database after the raw-SQL index lands.email.send's Inngest behaviour is byte-identical — theconfigobjectdefineJobbuilds for it is unchanged, asserted offfn.opts.
Context refs
lib/jobs/definitions/emailSend.ts—EMAIL_SEND_IDEMPOTENCY, the only declarationlib/jobs/defineJob.ts— where the option is forwarded to Inngest and dropped for the enginelib/jobs/engine/registry.ts—EngineJobDefinition, the field to addlib/jobs/engine/dispatcher.ts— the enqueue, and theP2002-as-success pattern to reuselib/jobs/dlq.ts— the replay key reshaping, on both armsprisma/schema.prisma—JobEvent.idempotencyKeyandJobQueueRun, and the comment naming this featuretests/jobs/email-send.test.ts— the existing coverage this must not break