Jobs
Jobs represent explicit work to do. Use a job when the code says "do this work" and one handler owns the work: send an email, process an import, sync a record, generate a report, or call a slow third-party API.
Beignet jobs are typed definitions. Dispatchers decide whether they run inline, in tests, or through a durable provider such as BullMQ or Inngest.
bun add @beignet/coreDefine a job
Create the app-bound defineJob builder once in lib/jobs.ts with
createJobs<AppContext>() (see
app-bound builders), then define jobs in
feature files:
import { retry } from "@beignet/core/jobs";
import { z } from "zod";
import { defineJob } from "@/lib/jobs";
export const SendWelcomeEmailJob = defineJob("mail.welcome", {
payload: z.object({
email: z.string().email(),
}),
retry: retry.exponential({
attempts: 3,
}),
async handle({ payload, ctx }) {
await ctx.ports.mailer.send({
to: payload.email,
subject: "Welcome",
text: "Thanks for joining.",
});
},
});The payload schema is validated before the job is dispatched and before durable
worker execution calls handle(...).
Dispatch jobs
Use cases dispatch jobs through ctx.ports.jobs:
await ctx.ports.jobs.dispatch(SendWelcomeEmailJob, {
email: user.email,
});That port can be an inline dispatcher in local development and tests, or a durable provider in production.
Inline dispatcher
Use the inline dispatcher when the work should run immediately in the same process:
import { createInlineJobDispatcher } from "@beignet/core/jobs";
const jobs = createInlineJobDispatcher<AppContext>({
ctx,
onError(error, job) {
ctx.ports.logger.error("Job failed", {
error,
jobName: job.name,
});
},
});Inline dispatch honors the job's declared retry policy in-process: a failed
handler retries with the policy's delays before the dispatch rejects. Jobs
without a retry policy run once, and payload validation failures never retry.
Pass sleep to replace the real backoff delays in tests, or retry: false
when another layer owns execution retries for every dispatch through the
dispatcher.
Retry policies run in exactly one layer. When an outbox drain delivers a job through the inline dispatcher, the drain runs the handler once per pass and reschedules failures itself using the job's policy — the inline retry loop does not stack on top. No configuration is needed; the drain detects the inline dispatcher automatically.
Job timeouts
Use timeout when a job attempt should fail after a bounded execution window:
import { retry } from "@beignet/core/jobs";
import { z } from "zod";
import { defineJob } from "@/lib/jobs";
export const GenerateReportJob = defineJob("reports.generate", {
payload: z.object({
reportId: z.string(),
}),
timeout: "30s",
retry: retry.exponential({ attempts: 3 }),
async handle({ payload, ctx, signal }) {
await ctx.ports.reports.generate(payload.reportId, { signal });
},
});Timeouts apply per attempt. When the window expires, Beignet throws
JobTimeoutError; retry policies then classify that timeout like any other
handler failure. Inline dispatchers and outbox-backed inline drains enforce
the timeout directly. createBullMQJobWorker(...) enforces it around the
registered handler. createInngestJobFunction(...) maps whole-second
timeouts to Inngest's timeouts.finish setting and fails fast for
millisecond-precision timeouts Inngest cannot honor.
The signal argument is cooperative cancellation. Beignet aborts it when the
timeout fires, but JavaScript cannot forcibly stop work that ignores the
signal, so pass it into cancellable provider calls where possible.
Job hooks
Use hooks for app-owned behavior that should wrap every handler attempt:
logging, tracing, tenant setup, per-job leases, or rate-limit checks. Hooks
receive the same parsed payload, context, job definition, and timeout signal as
the handler. Runner hooks wrap job-local hooks.
import type { JobDef, JobHook, StandardSchema } from "@beignet/core/jobs";
import { retry } from "@beignet/core/jobs";
import { z } from "zod";
import type { AppContext } from "@/app-context";
import { defineJob } from "@/lib/jobs";
export const logJobAttempts: JobHook<
JobDef<string, StandardSchema, AppContext>,
AppContext
> = async ({ job, ctx, attempt, maxAttempts }, next) => {
ctx.ports.logger.info("Job attempt started", {
jobName: job.name,
attempt: attempt ?? null,
maxAttempts: maxAttempts ?? null,
});
await next();
ctx.ports.logger.info("Job attempt completed", {
jobName: job.name,
attempt: attempt ?? null,
maxAttempts: maxAttempts ?? null,
});
};
export const GenerateReportJob = defineJob("reports.generate", {
payload: z.object({
reportId: z.string(),
}),
timeout: "30s",
retry: retry.exponential({ attempts: 3 }),
hooks: [logJobAttempts],
async handle({ payload, ctx, signal }) {
await ctx.ports.reports.generate(payload.reportId, { signal });
},
});Global hooks can be installed on execution runners:
createInlineJobDispatcher<AppContext>({
ctx,
hooks: [logJobAttempts],
});The same hooks option is available on createBullMQJobWorker(...) and
createInngestJobFunction(...). Hooks run inside the job timeout, and errors
thrown by hooks are classified by the same retry policy as handler errors. A
hook may skip next() to short-circuit the handler; that counts as a
successful attempt. When a runner reports attempt metadata, Beignet forwards it
to hooks with the same one-based attempt convention used by retry policies.
Direct job.handle(...) calls bypass hooks, so tests that need hook behavior
should dispatch the job or call runJobHandler(...).
Execution lease hooks
Use createJobExecutionLeaseHook(...) when one handler attempt should run at a
time for a logical job key. This is execution-time coordination: it does not
replace unique, which suppresses duplicate dispatches before work is queued.
import {
createJobExecutionLeaseHook,
type JobDef,
retry,
} from "@beignet/core/jobs";
import { z } from "zod";
import type { AppContext } from "@/app-context";
import { defineJob, logJobAttempts } from "@/lib/jobs";
const reportPayloadSchema = z.object({
reportId: z.string(),
workspaceId: z.string(),
});
const reportExecutionLease = createJobExecutionLeaseHook<
JobDef<"reports.generate", typeof reportPayloadSchema, AppContext>,
AppContext
>({
locks: ({ ctx }) => ctx.ports.locks,
key: ({ payload }) => payload.workspaceId,
ttl: "5m",
onUnavailable: "skip",
});
export const GenerateReportJob = defineJob("reports.generate", {
payload: reportPayloadSchema,
timeout: "30s",
retry: retry.exponential({ attempts: 3 }),
hooks: [logJobAttempts, reportExecutionLease],
async handle({ payload, ctx, signal }) {
await ctx.ports.reports.generate(payload.reportId, { signal });
},
});The helper performs one bounded LocksPort.acquire(...) call per attempt. It
does not start a renewal loop or background worker, so it is safe for
serverless entrypoints when locks is backed by shared storage such as the
Redis locks provider. Release is best effort; ttl is the real safety boundary
if the runtime is frozen or terminated before finally runs.
onUnavailable defaults to "skip", which treats an overlapping attempt as
successful without running the handler. Use "throw" to raise
JobExecutionLeaseUnavailableError and let the retry policy decide whether to
try again, or pass a function to log and optionally throw your own error.
Unique jobs
Use unique when duplicate dispatches of the same logical job should collapse
for a bounded time window. The uniqueness guard is dispatch-time coordination:
it prevents another enqueue while the lease is active, but it does not replace
handler idempotency for provider retries or worker crashes.
import {
createInlineJobDispatcher,
createUniqueJobDispatcher,
retry,
} from "@beignet/core/jobs";
import { z } from "zod";
import type { AppContext } from "@/app-context";
import { defineJob } from "@/lib/jobs";
const syncAccountPayloadSchema = z.object({
accountId: z.string().min(1),
});
export const SyncAccountJob = defineJob("billing.sync-account", {
payload: syncAccountPayloadSchema,
unique: ({ payload }) => ({
key: payload.accountId,
ttl: "10m",
}),
timeout: "30s",
retry: retry.exponential({ attempts: 3 }),
async handle({ payload, ctx, signal }) {
await ctx.ports.billing.syncAccount(payload.accountId, { signal });
},
});
export function createJobsPort(args: {
ports: Pick<AppContext["ports"], "locks">;
createBackgroundContext: () => AppContext;
}) {
const baseJobs = createInlineJobDispatcher<AppContext>({
ctx: args.createBackgroundContext,
});
return createUniqueJobDispatcher({
jobs: baseJobs,
locks: args.ports.locks,
});
}The concrete lock key is namespaced as
jobs:unique:<job-name>:<unique-key>. A successful dispatch keeps the lease
until ttl expires; a failed dispatch releases it so the caller can retry.
Wrap direct BullMQ or Inngest dispatchers the same way when an app wants
provider-backed dispatch plus Beignet-owned uniqueness.
Wrap root dispatchers for unique durable jobs. If you wrap a transaction-scoped outbox dispatcher, the lease is acquired before the transaction commits, so a rollback can still suppress duplicates until the TTL expires.
Durable dispatch with Inngest
Install the Inngest jobs provider when production jobs should be queued outside the request process:
bun add @beignet/provider-jobs-inngest @beignet/core inngestimport { createInngestJobsProvider } from "@beignet/provider-jobs-inngest";
export const providers = [createInngestJobsProvider()];createInngestJobsProvider(options) sets the app name and event key in code;
options override env-derived values.
The provider adapts Inngest's durable execution platform into Beignet's
JobDispatcherPort. It installs ctx.ports.jobs and exposes
ctx.ports.inngest.client as an escape hatch for Inngest-specific features.
It does not replace Beignet domain events; use listeners to turn domain facts
into Inngest-backed jobs.
Workers are defined separately from your Beignet HTTP server:
// app/api/inngest/route.ts
import { createInngestJobFunction } from "@beignet/provider-jobs-inngest";
import { serve } from "inngest/next";
import { SendWelcomeEmailJob } from "@/features/users/jobs";
import { getServer } from "@/server";
const server = await getServer();
const sendWelcomeEmail = createInngestJobFunction({
client: server.ports.inngest.client,
job: SendWelcomeEmailJob,
ctx: () => server.createServiceContext(),
instrumentation: server.ports,
});
export const { GET, POST, PUT } = serve({
client: server.ports.inngest.client,
functions: [sendWelcomeEmail],
});server.createServiceContext() is app-owned through the server context
blueprint. Passing server.ports as the instrumentation target lets Beignet
start the job span before resolving that lazy context, so tracing, logging, and
provider instrumentation share the active execution.
See Runtime recipes for how Beignet separates
provider adapters from serverless-safe worker entrypoints. Direct provider
jobs run through provider-owned entrypoints such as Inngest functions;
outbox-backed jobs run through beignet outbox drain.
When a job defines a retry policy, the Inngest helper maps the total attempt
count to Inngest's function retry setting, and
createInngestJobFunction(...) fails fast if a job policy includes custom
backoff, jitter, or retryIf behavior that Inngest cannot honor. Whole-second
job timeouts map to Inngest timeouts.finish; sub-second timeouts fail fast
because Inngest function timeouts cannot honor them exactly.
Durable workers with BullMQ
Install the BullMQ provider when production jobs should run through a Redis-backed queue that your app owns:
bun add @beignet/provider-jobs-bullmq @beignet/core bullmqimport { createBullMQJobsProvider } from "@beignet/provider-jobs-bullmq";
export const providers = [createBullMQJobsProvider()];The provider installs ctx.ports.jobs and exposes
ctx.ports.bullMQJobs.queue as an escape hatch for BullMQ-specific operations.
Run workers from an explicit worker process, CLI entrypoint, or provider-owned route:
// server/workers/jobs.ts
import { createBullMQJobWorker } from "@beignet/provider-jobs-bullmq";
import { SendWelcomeEmailJob } from "@/features/users/jobs";
import { getServer } from "@/server";
const server = await getServer();
export const jobsWorker = createBullMQJobWorker({
queueName: process.env.BULLMQ_QUEUE_NAME ?? "beignet-jobs",
redisUrl: process.env.BULLMQ_REDIS_URL ?? "redis://localhost:6379/0",
prefix: process.env.BULLMQ_PREFIX ?? "beignet",
jobs: [SendWelcomeEmailJob],
ctx: () => server.createServiceContext(),
instrumentation: server.ports,
workerOptions: {
concurrency: 5,
},
errorReporter: ({ ctx }) => ctx.ports.errorReporter,
});BullMQ workers are at-least-once. Put idempotency inside handlers when duplicate
execution would create a side effect. Use jobsWorker.close() from process
shutdown handling so BullMQ can stop claiming new jobs and wait for active jobs
to finish. The worker helper defaults to the same "beignet" Redis key prefix
as the provider; pass prefix when BULLMQ_PREFIX changes.
Register the app-owned OpenTelemetry SDK in this standalone process before
initializing the server. Passing server.ports starts the job span before the
lazy service context is created. See Observability for the
shared bootstrap pattern.
The BullMQ provider maps Beignet fixed and exponential retry attempts to
BullMQ attempts/backoff. retryIf is honored by
createBullMQJobWorker(...) with BullMQ unrecoverable failures. Retry fields
BullMQ cannot honor exactly, such as maxDelay, custom exponential factor,
and boolean jitter, fail fast. createBullMQJobWorker(...) also enforces
Beignet job timeouts around registered handlers and reports timeout failures
through the same retry/dead-letter classification path.
Use the provider escape hatch in health or readiness routes when the deployment needs to prove the direct jobs queue is reachable:
const health = await ctx.ports.bullMQJobs.checkHealth();
if (!health.ok) {
return Response.json({ ok: false, jobs: health }, { status: 503 });
}For direct BullMQ jobs, Beignet instrumentation records terminal worker
failures as deadLettered; BullMQ owns the concrete failed-job set. Use the
outbox instead when the application database must contain durable retry and
dead-letter rows for the side effect.
Retry policy
Use the retry helpers to make durable failure behavior explicit:
import { retry } from "@beignet/core/jobs";
class TemporaryProviderError extends Error {}
retry.none();
retry.fixed({
attempts: 3,
delay: "30s",
});
retry.exponential({
attempts: 5,
initialDelay: "10s",
maxDelay: "10m",
jitter: true,
retryIf: ({ error }) => error instanceof TemporaryProviderError,
});attempts is the maximum total attempts, including the first attempt. Use
retryIf for app-owned transient/permanent error classification.
Retry vocabulary
Beignet uses the same retry language for jobs, outbox-backed delivery, and scheduled work:
| Term | Meaning |
|---|---|
attempt | One-based failed execution attempt currently being classified or recorded. |
attempts | Maximum total attempts, including the first try. |
| retry | Run the same job again because the failed attempt is retryable. |
| backoff | Delay before the next retry. Fixed and exponential helpers compute this for Beignet-owned workers. |
| timeout | Maximum execution window for one handler attempt. |
| hook | App-owned behavior that wraps one handler attempt. |
| execution lease | TTL-backed lock acquired around one handler attempt to avoid overlapping execution for a logical job key. |
| terminal failure | A non-retryable failure or an exhausted retry policy. |
| dead letter | Durable terminal delivery state used by outbox-backed jobs. Direct job providers may expose their own failed-job set; Beignet instrumentation uses deadLettered for terminal provider-worker failures. |
Jobs and transactions
Avoid dispatching durable side effects before the database work commits. When a workflow uses Unit of Work, record a domain event during the transaction and let a listener dispatch the job after commit — see side effects after commit for the rule.
Use Outbox when the job enqueue must commit with the database
write. The outbox can sit behind a transaction-scoped tx.jobs dispatcher,
then a worker drains the durable row into your production job provider.
beignet make job registers new feature job registries in an existing
server/outbox.ts, and beignet doctor warns when a feature job is missing
from the outbox registry (--fix registers it).
Retry-safe jobs
Job providers may retry handlers after process failures, timeouts, or transient errors. Put idempotency inside the job handler when the handler owns work that must not happen twice:
import {
createIdempotencyFingerprint,
runIdempotently,
} from "@beignet/core/idempotency";
export const GenerateReportJob = defineJob("reports.generate", {
payload: z.object({
reportId: z.string(),
requestedBy: z.string(),
}),
retry: retry.exponential({ attempts: 3 }),
async handle({ payload, ctx }) {
await runIdempotently(ctx.ports.idempotency, {
namespace: "reports.generate",
key: payload.reportId,
scope: { actorId: payload.requestedBy },
fingerprint: await createIdempotencyFingerprint(payload),
ttlSec: 60 * 60 * 24,
run: () => ctx.ports.reports.generate(payload.reportId),
});
},
});Use Idempotency for the full command, webhook, and job pattern.
Testing
In use-case tests, pass a job dispatcher that records dispatches:
const dispatchedJobs: Array<{ name: string; payload: unknown }> = [];
const jobs = {
dispatch: async (job, payload) => {
dispatchedJobs.push({ name: job.name, payload });
},
};In job tests, call the job handler directly with an in-memory context:
await SendWelcomeEmailJob.handle({
job: SendWelcomeEmailJob,
payload: { email: "user@example.com" },
ctx,
});Where jobs fit
Workflow primitives gives the full decision guide for commands, events, jobs, schedules, notifications, idempotency keys, and outbox records, and the workflows overview shows the transition pattern that decides when jobs should be dispatched.