Implement defineJob's idempotency option on the Postgres engine, so a repeated email.send with the same key produces one delivery rather than two.
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.
Carry the key through registration, resolve it at dispatch, and let a UNIQUE CONSTRAINT do the deduplication.
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.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.)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.
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.
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.
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.P2002, never two rows and never a thrown error reaching the caller.idempotency is unaffected: two identical events produce two rows.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.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 the P2002-as-success pattern to reuselib/jobs/dlq.ts — the replay key reshaping, on both armsprisma/schema.prisma — JobEvent.idempotencyKey and JobQueueRun, and the comment naming this featuretests/jobs/email-send.test.ts — the existing coverage this must not break