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/coreChoose where jobs run
The job definition describes the work. Its dispatcher and execution entrypoint determine where it runs and who retries failures:
| Execution model | Use it when | What runs the handler |
|---|---|---|
| Inline | You want immediate execution in the calling process, including local checks and tests. | The dispatch call waits for the handler and any in-process retries. There is no durable queue. |
| Inngest | You want durable execution through Inngest. | Registered Inngest functions. |
| BullMQ | You want a Redis-backed queue and can run a worker process. | An app-owned BullMQ worker. |
An outbox is a separate choice: it makes recording the job intent atomic with a database write. Its drain can execute through the inline dispatcher or enqueue into a durable provider. With inline delivery the outbox owns retries; with a durable provider, the outbox retries enqueueing and the provider retries handler execution.
Start below with one inline job, then use Inngest or BullMQ when you need a durable queue. Dispatching to a durable provider requires its function endpoint or worker to be running.
Define a job
Use the SQLite Todos app from Quickstart. Generate a job from the app root:
bun beignet make job todos.log-completionThe generator creates the app-bound builder in lib/jobs.ts, declares and wires
ports.jobs, and adds the feature and outbox registries. In a starter with no jobs
provider, it installs an inline dispatcher. If the command adds outbox schema
files, review and apply the migration before booting the app:
bun beignet db generate
bun beignet db migrate
bun beignet db statusReplace the generated job with this complete file. A log makes execution visible without configuring another service; use the same shape for your application work:
// features/todos/jobs/log-completion.ts
import { z } from "zod";
import { defineJob } from "@/lib/jobs";
export const LogCompletionJobPayloadSchema = z.object({ id: z.uuid() });
export type LogCompletionJobPayload = z.infer<typeof LogCompletionJobPayloadSchema>;
export const LogCompletionJob = defineJob("todos.log-completion", {
payload: LogCompletionJobPayloadSchema,
async handle({ payload, ctx }) {
ctx.ports.logger.info("Todo completion processed", { todoId: payload.id });
},
});The payload schema is validated before dispatch and before durable worker execution. Durable transports preserve the original JSON-safe payload after producer validation; the worker applies handler-facing transforms once.
Dispatch jobs
In application code, import the feature job and dispatch through the app port:
// features/todos/use-cases.ts (excerpt)
await ctx.ports.jobs.dispatch(LogCompletionJob, { id: updated.id });Here LogCompletionJob comes from @/features/todos/jobs/log-completion, and
updated is the saved todo. Keep this call after the transaction resolves. Use
transactional enqueueing when a crash
between commit and dispatch must not lose the work.
A direct call uses the configured dispatcher immediately. Merely registering the
job in server/outbox.ts does not turn this call into an outbox write.
Verify a job
Create this complete test. It dispatches the documented job through the inline runner, checks the handler's output, and verifies that invalid payloads cannot reach the handler:
// features/todos/tests/log-completion.test.ts
import { expect, test } from "@/lib/beignet-test";
import { createInlineJobDispatcher } from "@beignet/core/jobs";
import { createServiceActor } from "@beignet/core/ports";
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
import type { AppContext } from "@/app-context";
import { LogCompletionJob } from "@/features/todos/jobs/log-completion";
import { initialPorts } from "@/infra/port-wiring";
test("dispatch executes the job and rejects an invalid payload", async () => {
const logs: unknown[] = [];
const fixture = createTestPorts<AppContext["ports"]>({
base: initialPorts,
overrides: {
gate: initialPorts.gate,
logger: { info: (message, fields) => { logs.push({ message, fields }); } },
},
});
const createContext = createTestContextFactory<AppContext, AppContext["ports"]>({
ports: fixture.ports,
actor: createServiceActor("job-test"),
auth: null,
});
const jobs = createInlineJobDispatcher<AppContext>({ ctx: () => createContext() });
const id = crypto.randomUUID();
await jobs.dispatch(LogCompletionJob, { id });
expect(logs).toEqual([{
message: "Todo completion processed", fields: { todoId: id },
}]);
await expect(jobs.dispatch(LogCompletionJob, { id: "not-a-uuid" })).rejects.toThrow();
expect(logs).toHaveLength(1);
});bun run typecheck
bun run test features/todos/tests/log-completion.test.ts
bun beignet doctor --strictExpect the test to pass with exactly one handler log. This checks definition, validation, and execution. To verify a durable provider, also start its execution entrypoint, dispatch a job, and check completion in that provider's dashboard or worker logs.
Durable dispatch with Inngest
Install the Inngest jobs provider when production jobs should be queued outside the request process:
bun beignet provider add jobs-inngest
bun installUse Inngest v4 with Beignet's Node.js 22.12-or-newer runtime baseline.
The preset adds the client, provider, registry, and route. The following snippets show the generated composition; keep other entries in your provider registry.
// server/providers.ts (excerpt)
import { createInngestJobsProvider } from "@beignet/provider-jobs-inngest";
import { inngest } from "@/infra/inngest";
export const providers = [createInngestJobsProvider({ client: inngest })];The injected client keeps event dispatch and function registration on one
identity. The SDK reads INNGEST_* cloud credentials from the environment.
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.
Keep the function registry separate from the HTTP adapter. beignet make job
updates inngestJobs when the provider is installed:
// server/inngest.ts (excerpt)
import { createServiceActor } from "@beignet/core/ports";
import { createInngestJobFunctions } from "@beignet/provider-jobs-inngest";
import { todoJobs } from "@/features/todos/jobs";
import { inngest } from "@/infra/inngest";
import { getServer } from "@/server";
export const inngestJobs = [...todoJobs] as const;
export const inngestFunctions = createInngestJobFunctions({
client: inngest,
jobs: inngestJobs,
ctx: async () => {
const server = await getServer();
return server.createServiceContext({
actor: createServiceActor("beignet-inngest"),
});
},
instrumentation: async () => (await getServer()).ports,
errorReporter: async () => (await getServer()).ports.errorReporter,
});// app/api/inngest/route.ts (excerpt)
import { serve } from "inngest/next";
import { inngest } from "@/infra/inngest";
import { inngestFunctions } from "@/server/inngest";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: inngestFunctions,
});The lazy server resolvers run inside each function invocation, avoiding eager
provider startup when Next.js imports route modules during a production build.
Use INNGEST_DEV=1 only with the local dev server. Production requires
INNGEST_EVENT_KEY for dispatch and INNGEST_SIGNING_KEY for endpoint
verification; beignet preflight checks both.
Non-Next presets expose createAppInngestFunctions(...) from
server/inngest.ts, requiring the host to bind its app-owned service context
before mounting Inngest's runtime adapter.
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.
For retry mapping, timeout limits, and terminal failure reporting, see Inngest execution options.
Durable workers with BullMQ
Install the BullMQ provider when production jobs should run through a Redis-backed queue that your app owns:
bun beignet provider add jobs-bullmq
bun installThe preset wires the producer in server/providers.ts; you create and run the
worker separately. Preserve the other entries in the generated provider registry:
// server/providers.ts (excerpt)
import { 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.
Set BULLMQ_REDIS_URL in .env.local to a running Redis instance, for example
redis://localhost:6379/0. Use the same environment in the web and worker
processes, including BULLMQ_QUEUE_NAME and BULLMQ_PREFIX if you customize them.
Apply the database migrations from Define a job before starting
either process.
Create this complete worker entrypoint. Run it as a long-running process, outside serverless route modules and provider startup:
// server/workers/jobs.ts
import { createBullMQJobWorker } from "@beignet/provider-jobs-bullmq";
import { LogCompletionJob } from "@/features/todos/jobs/log-completion";
import { getServer } from "@/server";
const redisUrl = process.env.BULLMQ_REDIS_URL;
if (!redisUrl) throw new Error("Set BULLMQ_REDIS_URL before starting the jobs worker.");
const server = await getServer();
export const jobsWorker = createBullMQJobWorker({
queueName: process.env.BULLMQ_QUEUE_NAME ?? "beignet-jobs",
redisUrl,
prefix: process.env.BULLMQ_PREFIX ?? "beignet",
jobs: [LogCompletionJob],
ctx: () => server.createServiceContext(),
instrumentation: server.ports,
workerOptions: {
concurrency: 5,
},
errorReporter: ({ ctx }) => ctx.ports.errorReporter,
infrastructureErrorReporter: server.ports.errorReporter,
});
let shutdownPromise: Promise<void> | undefined;
function shutdown(exitCode: number, force = false): Promise<void> {
shutdownPromise ??= (async () => {
const deadline = setTimeout(() => process.exit(1), 25_000);
deadline.unref();
try {
try {
await jobsWorker.close({ force });
} finally {
await server.stop();
}
} catch (error) {
console.error("BullMQ worker shutdown failed", error);
exitCode = 1;
} finally {
clearTimeout(deadline);
}
process.exit(exitCode);
})();
return shutdownPromise;
}
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => { void shutdown(0); });
}
const health = await jobsWorker.checkHealth({ timeoutMs: 5_000 });
if (!health.ok) {
console.error("BullMQ worker failed readiness", health.error.message);
await shutdown(1, true);
}
server.ports.logger.info("BullMQ jobs worker started");From the app root, run the worker in its own terminal. Bun loads .env.local for
this process:
bun run server/workers/jobs.tsExpect BullMQ jobs worker started. With the web app running in another terminal,
dispatch LogCompletionJob as shown in Dispatch jobs. Expect
Todo completion processed in the worker terminal. Stop the worker with Ctrl+C;
it stops claiming jobs, waits for active handlers, and then closes the server.
Add every job this queue can receive to the worker's jobs registry.
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. Put a hard shutdown deadline in the app-owned process entrypoint;
pass { force: true } only when unfinished jobs may safely return through
BullMQ's stalled-job recovery. The worker helper defaults to the same
"beignet" Redis key prefix as the provider; pass prefix when
BULLMQ_PREFIX changes.
For connection behavior, retention, readiness, and error reporting, see BullMQ operations.
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).
Registry membership does not install a dispatcher. The generator also ensures
jobs: JobDispatcherPort is declared and bound, using an app-owned inline
provider only when the app has no existing dispatcher. Doctor warns when an
outbox job registry has no bound or deferred jobs port, and drains reject
that configuration before claiming messages.
If you later add the jobs-inngest or jobs-bullmq provider preset, the CLI
replaces only its marked generated inline fallback. It rejects an unmarked
app-owned inline dispatcher as a provider conflict so custom execution wiring
is never removed implicitly.
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-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.
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. If a
handler ignores the signal, its original promise may continue after
JobTimeoutError is reported and a retry-capable runner may start another
attempt before the first one settles. Make timeout-prone handlers idempotent,
propagate the signal through every cancellable operation, and treat timeouts as
terminal when overlapping attempts would be unsafe.
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.
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.
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.
Inngest execution options
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. Jobs without an
explicit attempt count use retries: 0 instead of Inngest's provider default.
Incoming events that fail the job payload schema raise Inngest's
NonRetriableError, so Inngest does not retry permanently malformed work.
Beignet records that terminal execution as deadLettered when instrumentation
is configured. Other failures continue through Inngest's normal retry handling
and are not reclassified by Beignet.
For Inngest functions, pass errorReporter to
createInngestJobFunction(...). Beignet installs a native onFailure handler
that reports only after Inngest exhausts provider retries, with stable
function/run/job identifiers and no event payload.
BullMQ operations
startupTimeoutMs and worker or queue health-check timeoutMs values must be
integers from 1 through 2,147,483,647 milliseconds, the JavaScript timer range.
Beignet-created producer connections disable the Redis offline queue and bound request retries so an HTTP dispatch fails instead of waiting indefinitely for Redis. Beignet-created workers use persistent consumer connection settings. Explicit connection objects remain app-owned.
Redis URL query parameters follow IORedis URL semantics, so options such as
?family=6&connectTimeout=2500 reach both producer and worker connections.
Beignet still enforces its URL-created safety profile: producer URLs cannot
override the disabled offline queue or bounded request retries, and worker URLs
cannot override maxRetriesPerRequest: null. Use an explicit connection object
for options that require booleans, nested objects, or callbacks. BullMQ does not
support IORedis keyPrefix; Beignet rejects it in Redis URLs and uses the
provider or worker prefix option for queue key namespacing instead.
Completed jobs are retained for 24 hours up to 1000 rows, and failed jobs for
7 days up to 5000 rows. Configure retention on the provider when queue volume
or incident policy needs different limits; pass false only when
defaultJobOptions or per-job BullMQ options own finalized-job cleanup.
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.
BullMQ and Inngest dispatchers capture the active Beignet trace through their instrumentation target and place a versioned carrier in provider transport data. Their worker/function helpers accept both traced envelopes and legacy raw payloads, then continue the producer trace before resolving lazy app context. The carrier preserves the original producer payload after validation; the worker or function applies the handler-facing schema transform once. Malformed carrier metadata is ignored rather than failing the job.
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.
An errorReporter configured on the worker captures terminal failures once.
Retryable attempts remain retryScheduled instrumentation and logs; they do
not create warning incidents.
Pass infrastructureErrorReporter as the context-free fallback when the
per-job errorReporter is a resolver. It captures payload validation,
context-construction, and unknown-job failures that happen before app context
exists, plus worker-level Redis errors, stalled jobs, and shutdown failures.
Beignet selects the primary reporter when it can resolve one and does not
intentionally fan a failure out to both reporters. Production Redis must use a
non-evicting memory policy and appropriate persistence. Job payloads are stored
in Redis, so prefer identifiers over secrets or unnecessary personal data.
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. |
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 LogCompletionJob.handle({
job: LogCompletionJob,
payload: { id: crypto.randomUUID() },
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.