1.6.4 Patterns: retry policies, dead-letter queue, scheduled-job primitive, RLS on `job_run` + `job_run_dlq`
Estimate: 28m · Depends on: 1.6.3
Codify the cross-cutting patterns every future job (Epic 5 notifications, Epic 7 LLM calls, Epic 6 search indexing) will follow. Five concrete moves:
- Retry policy module (
lib/jobs/retries.ts): three named policiesdefineJobaccepts via aretryPolicyshorthand —"transient"(3 attempts, exponential),"idempotent"(5 attempts, longer backoff — safe for read-only or naturally-idempotent operations),"none"(1 attempt, fail fast — for system jobs where retry semantics are wrong, like "send signup notification once or not at all"). The wrapper translates the policy to Inngest'sretries+cancelOnconfiguration; the policy choice is documented per-job and visible in the dashboard. - Dead-letter queue:
- Prisma migration adds
job_run_dlq:id(cuid),workspace_id(FK, nullable for system events),function_id,event_name,event_data(jsonb),failure(jsonb),attempts(int),first_failed_at,last_failed_at,replayed_at(nullable). Indexes:(workspace_id, last_failed_at desc). defineJob's failure path: after the final retry exhausts, write ajob_run_dlqrow inside atxthat also flips thejob_run.statustofailed. This is the durable record the dashboard reads from; Inngest's own failure surface stays available for deep tracing but isn't the source of truth for operator action.lib/jobs/dlq.tsexposesreplayDLQ(dlqId, tx)— the service-layer function the dashboard's "Replay" button calls. Re-emits the original event viasendEvent(), sets the DLQ row'sreplayed_at. Replay is auditable; a workspace owner can see when a DLQ entry was retried and by whom (reuse Story 1.5's identity propagation through Server Actions).
- Prisma migration adds
- Scheduled-job primitive:
defineJobaccepts an optionalcronfield. Inngest's{ cron: "0 9 * * *" }trigger pattern lets us schedule jobs without a separate scheduler service. Document the canonical cron usage indocs/jobs.md: scheduled jobs emit a synthetic event so the dashboard treats them uniformly with event-triggered jobs (thejob_runrow'sevent_nameis"scheduled.{job_id}"). Add a placeholdersystem.daily-health-checkjob (cron"0 9 * * *") that no-ops and writes ajob_runrow — proves the scheduled path works end-to-end. Replaces the 1.6.2system.pingsmoke job. - RLS on
job_run+job_run_dlq: follow the Story 1.2 workspace-RLS pattern exactly — a policy that filters rows byworkspace_id = current_setting('prodect.workspace_id')::text. System events (workspace_id IS NULL) are visible only when the request context setsprodect.system_admin = 'true'(the same escape hatch Story 1.2 ships for cross-workspace admin tooling). Test: cross-workspace reads return zero rows even when the row exists; system-event reads succeed only with the admin context set. - Documentation:
docs/jobs.mdgrows three new sections — Retry policies (when to pick each named policy + examples), Dead-letter queue (operator runbook: how DLQ rows appear, how to replay, when NOT to replay), Scheduled jobs (cron syntax, the synthetic-event convention, how scheduled-job failures surface in the dashboard).
Why bundle these four patterns into one Subtask: they share the same migration boundary (the DLQ table + RLS policies on both tables ship together as a single Prisma migration), they share the same wrapper surface (defineJob grows three new options at once), and they share the documentation block. Splitting them would mean three Prisma migrations and three wrapper-API edits stacked across three PRs against the same files — a high-collision shape per past parallel-Subtask lessons.
Acceptance criteria
lib/jobs/retries.tsships the three named policies;defineJobaccepts aretryPolicyshorthand and maps it to the underlying Inngest config.- Prisma migration adds
job_run_dlqper the schema in the description; RLS policies on bothjob_runandjob_run_dlqmirror the Story 1.2 workspace-scope pattern (with the system-admin escape hatch). defineJob's failure path writes ajob_run_dlqrow inside a transaction that also flipsjob_run.statustofailed; verified by a Vitest spec that forces a deliberate handler failure beyond the retry budget and asserts both rows exist.lib/jobs/dlq.tsexportsreplayDLQ(dlqId, tx); calling it re-emits the original event and setsreplayed_at; idempotency-keyed events stay deduped after replay (same key → no double-execute) — this interaction is documented indocs/jobs.mdwith the workaround (re-shape the idempotency key when a code change makes the original a no-op, or wait the window out).defineJobaccepts an optionalcronfield; thesystem.daily-health-checkjob exists in the registry with a documented cron expression; the 1.6.2system.pingsmoke job is removed (replaced by this).- RLS specs in
tests/jobs/rls.test.tscover: cross-workspace reads return zero rows; system-event reads require theprodect.system_admincontext; the existing workspace-isolation E2E spec is extended with a job-run isolation case. docs/jobs.mdgrows the three new sections (Retry policies, DLQ, Scheduled jobs) with worked examples for each.- All quality gates green; existing tests + E2E stay green.
Context refs
motir-core/CLAUDE.md— 4-layer rule (auto-loaded)prisma/sql/+ Story 1.2's RLS migration — the canonical RLS pattern to mirrorlib/db.ts+ the workspace-context middleware from Story 1.2 — howprodect.workspace_idgets set on the sessionlib/jobs/*from 1.6.2 + 1.6.3 — the wrapper to extend- Inngest retries + cron triggers docs
- The 1.6.3
email.sendjob — concrete exemplar to retrofit with a named retry policy