Skip to content

Defining jobs

A job definition is a class produced by Job.make(name, options): a unique name (the _tag), a schema-typed payload, and everything both sides of the contract need. Producers get enqueue/execute/poll; runners get toLayer. Define it once in shared code; the name is also the persistence key, so keep it stable.

ts
import { Job } from "effect-mq"
import { Schema } from "effect"

class GenerateInvoice extends Job.make("generate-invoice", {
  payload: { invoiceId: Schema.String, companyId: Schema.String },
  success: Schema.String,
  error: InvoiceError,
  idempotencyKey: ({ invoiceId }) => invoiceId,
  metadata: ({ companyId }) => ({ companyId }),
  retryable: (e) => e.reason !== "invoice-voided",
  queue: "billing",
  defaults: {
    attempts: 5,
    backoff: { type: "exponential", delay: "1 second" },
    timeout: "2 minutes"
  }
}) {}

Everything Job.make accepts:

OptionTypePurpose
payloadstruct fields or a Schema.Structwhat producers pass in, what handlers receive
successschema (default Schema.Void)the handler's result, decodable via awaitResult/attempts
errorschema or list (default Schema.Never)the handler's typed failure, round-trips through storage; a list of schemas becomes their union
idempotencyKey(payload) => stringderive a stable job id from business data
dedupe(payload) => DedupeInputtemporal dedup key; see Deduplication
metadata(payload) => Record<string, string>queryable business context, indexed by every driver
retryable(error) => booleanreturn false to skip the remaining retry budget
queuestring (default "default")ordering/concurrency domain within the store
storeJobStore.named(...) keywhich named store hosts this job
defaultsJobOptionsdefault enqueue options, overridable per enqueue

Payload schemas

payload takes either bare struct fields (shorthand, wrapped in Schema.Struct for you) or any struct schema you already have. Enqueue persists payloads schema-encoded as JSON and the worker decodes them back, so the full schema toolkit round-trips: branded types, unions, Schema.DateTimeUtc, Schema.Redacted. Your handler receives real domain values.

ts
class Remind extends Job.make("remind", {
  payload: {
    userId: UserId,                 // branded: enforced at the enqueue site
    remindAt: Schema.DateTimeUtc    // a real DateTime in the handler
  }
}) {}

success and error follow the same rule: the worker encodes handler exits through an exit schema and persists them in the attempt ledger, so MyJob.awaitResult(id) in another process returns the typed success or the typed failure.

A job that can fail more than one way takes error as a list, and the definition unions the members for you. retryable, execute, awaitResult, and the decoded ledger all see the union type:

ts
class GenerateInvoice extends Job.make("generate-invoice", {
  payload: { invoiceId: Schema.String },
  error: [InvoiceNotFound, PaymentDeclined, ProviderTimeout],
  retryable: (error) => error._tag === "ProviderTimeout"
}) {}

Redacted fields

Schema.Redacted has transport semantics. It does not encrypt values at rest:

  • Schema.Redacted(Schema.String) round-trips. Enqueue writes the underlying value into the payload JSON in your store; the worker-side decode re-wraps it, so handlers receive a real Redacted value that prints <redacted> in logs. Redaction protects logs and inspection, not the database.
  • Schema.Redacted(inner, { disallowJsonEncode: true }) refuses persistence entirely: enqueueing dies with Cannot serialize Redacted before anything reaches the store. Use it for values that must never be written down, and hand them to handlers via context/services instead.

Tests pin both behaviors.

Idempotency

idempotencyKey derives the job id from the payload: the id becomes <name>/<key> (e.g. generate-invoice/inv-42). Two things fall out of a deterministic id:

  • Dedup by id. Enqueueing while a job with that id exists is a silent no-op returning the existing id. Retrying an API request cannot double-enqueue.
  • A natural join key. Because the id is computable from business data, your own tables can reference queue records without storing job ids: the queue's retention can prune freely while your domain history keeps the key. See Metadata vs. your own tables for the pattern.
ts
idempotencyKey: ({ invoiceId }) => invoiceId
// GenerateInvoice.enqueue({ invoiceId: "inv-42", ... }) always yields "generate-invoice/inv-42"

An explicit per-enqueue jobId overrides the derived id. For temporal policy (throttle, debounce, replace) that leaves your ids alone, use dedupe, a separate key with its own lifecycle: Deduplication.

Metadata

metadata projects the payload into a flat Record<string, string> at enqueue time. Every driver indexes it, so it powers store.list({ metadata: { companyId } }), dashboard filters, and (on Postgres) GIN-indexed containment queries, all without deserializing payloads. Per-enqueue metadata merges over the definition's.

Keep it to ops-UI context ("list runs for company X"). Domain results belong in your own tables, joined by the idempotency-keyed id.

Retry control

retryable inspects a typed handler failure; returning false skips the remaining retry budget and fails the job immediately. Use it for errors where retrying cannot help (a voided invoice, a deleted customer). Its per-failure counterpart is Effect.fail(Job.unrecoverable(error)) inside the handler. Full retry semantics live in Retries & timeouts.

Queue and store

queue names an ordering/concurrency domain within a store; workers set per-queue concurrency with Worker.layer({ queues: { billing: { concurrency: 5 } } }). store binds the job to a named store (JobStore.named("durable")), an infrastructure/durability domain hosting many queues; forgetting to provide that store's layer is a compile error at every enqueue site. See Named stores for running Postgres and Redis side by side.

Defaults

defaults accepts any per-enqueue tuning option; per-enqueue values override per option:

OptionDefaultMeaning
delay0run this long after enqueue (per-enqueue also offers absolute at)
priority0higher runs first; ties are FIFO
attempts1total attempts including the first run (1 = no retries)
backoffimmediate{ type: "fixed" | "exponential", delay, factor? }; factor defaults to 2
keepforeverterminal-record retention: flat { count, age } or split per state
timeoutnoneper-run limit; the worker interrupts the handler fiber and the run counts as a failed attempt

All durations take Duration.Input ("30 seconds", Duration.minutes(2), millis). keep splits by terminal state when completed jobs are noise and failed ones evidence: { completed: { age: "1 day" }, failed: { age: "30 days" } }. Details in Retention.

TIP

Put policy that belongs to the job (its retry budget, its timeout) in defaults, and keep per-enqueue options for what varies per call (delay/at, priority). Every enqueue site inherits the definition, so tuning lives in one place.

Where to next