--- title: "Run a background job" description: "Extend the Projects app with a job saved in SQLite alongside a project, deliver it through an outbox drain, and verify retries and rollback." url: "https://www.beignetjs.com/guides/background-jobs" markdown_url: "https://www.beignetjs.com/markdown/guides/background-jobs.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "f27ffc691ada2988ce24a71558d3c529d53a9c1d" source_modified: true --- # Run a background job Create a project now and process work after the request. This guide extends the Projects tutorial with a job that reads the saved project and logs its ID. You will record the job in SQLite alongside the project, deliver it through an outbox drain, and test retries and rollback. The log gives you a visible result without another service. Replace the handler with your application work when you need to generate a report or call an integration. Jobs that charge money, send messages, or make other lasting changes also need [idempotent handlers](https://www.beignetjs.com/markdown/jobs.md#retry-safe-jobs). ## Before you start Complete the [Projects tutorial](https://www.beignetjs.com/markdown/learn.md), including ownership and the UI, using the Next.js starter and SQLite. Keep its database, signed-in account, and app configuration. Run commands from that app's root with Bun. Other runtimes and job providers have their own setup in the [Jobs reference](https://www.beignetjs.com/markdown/jobs.md). This guide uses the existing outbox adapters and the generated inline job runner. The outbox saves pending work; the runner executes it when a drain picks it up. An inline dispatch by itself would run in the caller, so the create use case will **enqueue into the transaction's outbox**, rather than execute the handler. You will edit these files after generating the job: | File | Change | | ------------------------------------------------ | ------------------------------------------------------------- | | `features/projects/jobs/log-created.ts` | Define the payload and work to perform | | `features/projects/use-cases/create-project.ts` | Save the project and job in one transaction | | `infra/db/provider.ts` | Share the existing transaction factory with the database test | | `features/projects/tests/background-job.test.ts` | Check queued delivery, retry, validation, and rollback | ## Generate the job ```bash bun beignet make job projects.log-created bun install bun run format bun beignet db generate bun beignet db migrate bun beignet db status ``` The generator adds `lib/jobs.ts`, the job and feature registry, the central `server/outbox.ts` registry, an outbox table, and the jobs provider. It also wires the transaction's outbox and creates `app/api/cron/outbox/drain/route.ts`. Review and keep the new migration. If `CRON_SECRET` is absent from `.env.local`, copy its generated entry from `.env.example` before starting the app. Keep those generated registrations. Replace the generated job with this complete file: ```typescript // features/projects/jobs/log-created.ts import { z } from "zod"; import { defineJob } from "@/lib/jobs"; export const LogCreatedJobPayloadSchema = z.object({ id: z.uuid() }); export type LogCreatedJobPayload = z.infer; export const LogCreatedJob = defineJob("projects.log-created", { payload: LogCreatedJobPayloadSchema, async handle({ payload, ctx }) { const project = await ctx.ports.projects.findById(payload.id); if (!project) return; ctx.ports.logger.info("Project creation processed", { projectId: project.id, }); }, }); ``` Pass a project ID rather than a request context or a user object. The handler loads the current record through the repository port. If the project has been deleted before delivery, there is no work left to do. ## Record the job with the project Apply this change to the create use case: ```diff --- a/features/projects/use-cases/create-project.ts +++ b/features/projects/use-cases/create-project.ts @@ -1,4 +1,6 @@ import "@beignet/core/server-only"; +import { enqueueJob } from "@beignet/core/outbox"; +import { LogCreatedJob } from "../jobs/log-created"; import { requireUserId } from "@beignet/core/ports"; import { useCase } from "../../../lib/use-case"; import { CreateProjectInputSchema, ProjectSchema } from "../schemas"; @@ -9,7 +11,14 @@ .output(ProjectSchema) .run(async ({ ctx, input }) => { const userId = requireUserId(ctx); - const project = await ctx.ports.projects.create({ ...input, userId }); - - return project; + return ctx.ports.uow.transaction(async (tx) => { + const project = await tx.projects.create({ ...input, userId }); + await enqueueJob( + tx.outbox, + LogCreatedJob, + { id: project.id }, + { maxAttempts: 3 }, + ); + return project; + }); }); ``` `requireUserId` still runs before the write. Both `tx.projects` and `tx.outbox` use the same database transaction. A failure rolls back the project and pending job together. The outbox allows up to three delivery attempts for this job. Keep this distinction when adapting the example: `enqueueJob(tx.outbox, ...)` records work for later. `ctx.ports.jobs.dispatch(...)` uses the configured runner immediately; the starter's inline runner would execute the handler in the request. ## Share the transaction setup with the test Extract the existing transaction factory in `infra/db/provider.ts`. The app and the isolated database test will then use the same repository and outbox wiring: ```diff --- a/infra/db/provider.ts +++ b/infra/db/provider.ts @@ -1,4 +1,5 @@ import "@beignet/core/server-only"; +import type { UnitOfWorkPort } from "@beignet/core/ports"; import { createProvider } from "@beignet/core/providers"; import { createDrizzleSqliteIdempotencyPort, @@ -7,7 +8,7 @@ createDrizzleSqliteUnitOfWork, type DbPort, } from "@beignet/provider-db-drizzle/sqlite"; -import type { AppPorts } from "@/ports"; +import type { AppPorts, AppTransactionPorts } from "@/ports"; import { ensureDatabaseReady } from "./database-ready"; import { createRepositories } from "./repositories"; import type * as schema from "./schema"; @@ -38,14 +39,7 @@ idempotency, outbox, outboxAdmin, - uow: createDrizzleSqliteUnitOfWork({ - db: dbPort.drizzle, - createTransactionPorts: (tx) => ({ - ...createRepositories(tx), - idempotency: createDrizzleSqliteIdempotencyPort(tx), - outbox: createDrizzleSqliteOutboxPort(tx), - }), - }), + uow: createAppUnitOfWork(dbPort.drizzle), }; return { @@ -56,3 +50,16 @@ }; }, }); + +export function createAppUnitOfWork( + db: DbPort["drizzle"], +): UnitOfWorkPort { + return createDrizzleSqliteUnitOfWork({ + db, + createTransactionPorts: (tx) => ({ + ...createRepositories(tx), + idempotency: createDrizzleSqliteIdempotencyPort(tx), + outbox: createDrizzleSqliteOutboxPort(tx), + }), + }); +} ``` Every port returned by `createTransactionPorts` receives the same `tx` handle. Keep this factory in the database provider so future generators can extend it. ## Test delivery and rollback Add this complete test. It calls the real create use case against an isolated SQLite database, then drains the registered job. Its first delivery fails on purpose; advancing the test clock makes the retry eligible without waiting. ```typescript // features/projects/tests/background-job.test.ts import { createUseCaseTester } from "@beignet/core/application"; import { createInlineJobDispatcher } from "@beignet/core/jobs"; import { drainOutbox, enqueueJob } from "@beignet/core/outbox"; import { createTestContextFactory, createTestPorts, createTestUserActor, } from "@beignet/core/testing"; import { createDrizzleSqliteOutboxAdminPort, createDrizzleSqliteOutboxPort, } from "@beignet/provider-db-drizzle/sqlite"; import type { AppContext } from "@/app-context"; import { LogCreatedJob } from "@/features/projects/jobs/log-created"; import { createProjectUseCase } from "@/features/projects/use-cases"; import { createAppUnitOfWork } from "@/infra/db/provider"; import { user } from "@/infra/db/schema"; import { createTestDatabase } from "@/infra/db/test-database"; import { initialPorts } from "@/infra/port-wiring"; import { expect, test } from "@/lib/beignet-test"; import { outboxRegistry } from "@/server/outbox"; test("project jobs wait for a drain, retry failures, and roll back with the write", async () => { const database = await createTestDatabase(); const received: string[] = []; let failDelivery = true; try { const owner = { id: "job-owner", name: "Owner", email: "job-owner@example.com", }; await database.db.insert(user).values({ ...owner, createdAt: new Date(), updatedAt: new Date(), }); const uow = createAppUnitOfWork(database.db); const outbox = createDrizzleSqliteOutboxPort(database.db); const admin = createDrizzleSqliteOutboxAdminPort(database.db); const fixture = createTestPorts({ base: initialPorts, overrides: { projects: database.repositories.projects, uow, logger: { info(message) { if (failDelivery) throw new Error("Temporary delivery failure"); received.push(message); }, }, }, }); const createContext = createTestContextFactory< AppContext, AppContext["ports"] >({ ports: fixture.ports, actor: createTestUserActor(owner.id), auth: { user: owner, session: { id: "job-session" } }, }); const tester = createUseCaseTester(createContext); const project = await tester.run(createProjectUseCase, { name: "Background work", }); expect( await database.repositories.projects.findById(project.id), ).not.toBeNull(); expect(await admin.countMessages({ status: "pending" })).toBe(1); expect(received).toEqual([]); const jobs = createInlineJobDispatcher({ ctx: createContext }); let now = new Date(); const drain = () => drainOutbox({ outbox, registry: outboxRegistry, jobs, retryDelayMs: 1000, now: () => now, }); expect((await drain()).retried).toBe(1); failDelivery = false; now = new Date(now.getTime() + 1000); expect((await drain()).delivered).toBe(1); expect(received).toEqual(["Project creation processed"]); expect(await admin.countMessages({ status: "delivered" })).toBe(1); expect((await drain()).claimed).toBe(0); let rolledBackId = ""; await expect( uow.transaction(async (tx) => { const unsaved = await tx.projects.create({ name: "Roll back", userId: owner.id, }); rolledBackId = unsaved.id; await enqueueJob(tx.outbox, LogCreatedJob, { id: unsaved.id }); throw new Error("Cancel transaction"); }), ).rejects.toThrow("Cancel transaction"); expect( await database.repositories.projects.findById(rolledBackId), ).toBeNull(); expect(await admin.countMessages()).toBe(1); await expect( enqueueJob(outbox, LogCreatedJob, { id: "not-a-uuid" }), ).rejects.toThrow(); expect(await admin.countMessages()).toBe(1); } finally { await database.close(); } }); ``` ```bash bun run format bun run test features/projects/tests/background-job.test.ts bun beignet check ``` Expect the test to pass: the job stays pending until drained, a failed attempt retries, successful delivery logs once in this test, a subsequent drain finds nothing, and a rollback saves neither the project nor its job. An invalid payload must also be rejected. Passing this test does not imply exactly-once delivery in production. ## Try it in the app Restart the development server after the generated environment and schema changes: ```bash bun run dev ``` Sign in at [localhost:3000](http://localhost:3000) and open [Projects](http://localhost:3000/projects). Create a project named **Background work**. Expect it to appear in the list and remain there after a reload. The generated Next.js provider requests a bounded drain after the response. Look in the server terminal for **Project creation processed** with the new project's ID. From a second terminal in the same app directory, inspect and drain: ```bash bun beignet outbox list --status pending bun beignet outbox drain --batch-size 100 bun beignet outbox list --status delivered ``` Expect a delivered `projects.log-created` message. If automatic delivery already ran, the pending list can be empty and the CLI drain can report zero claimed messages. If the CLI performs the delivery, the log appears in that terminal. Creating a second project records another job; editing an existing project does not trigger this create-only workflow. | What you see | What to check | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | No outbox table | Apply the generated migration to the same database used by the app. | | Pending messages but no log | Run the drain and inspect its result; a pending row means the work has not finished. | | No pending messages | Check the delivered list: the after-response drain may have finished already. | | A retry or dead-letter result | Read the failure with `bun beignet outbox show ` and fix the handler or dependency. Follow [recovery steps](https://www.beignetjs.com/markdown/outbox.md#dead-letter-recovery-and-cleanup) before requeueing. | ## Keep delivery running in production An after-response trigger can be missed during a crash or deployment. Schedule the generated authenticated drain route as a recovery sweep, including for scheduled retries. See [outbox drain deployment](https://www.beignetjs.com/markdown/runtime-recipes.md#outbox-drains) for host setup. Keep the application and drain pointed at the same database; a local SQLite file is not shared between separate hosts. Delivery is **at least once**. A crash after the handler succeeds but before the outbox acknowledges it can cause another execution. Duplicate log lines are acceptable here. Use [idempotency](https://www.beignetjs.com/markdown/idempotency.md) for effects that must not repeat, and recheck current access in handlers when background work acts on behalf of a user. For long-running work or a separate queue, choose [Inngest](https://www.beignetjs.com/markdown/jobs.md#durable-dispatch-with-inngest) or [BullMQ](https://www.beignetjs.com/markdown/jobs.md#durable-workers-with-bullmq). With either provider, keep the outbox drain running to enqueue committed work, and run the provider's function endpoint or worker to execute it. An outbox delivery then confirms queue acceptance, not handler completion. ## Work with your agent Use this prompt from the completed Projects app: ```text Read https://beignetjs.com/markdown/agent-instructions.md and this app's local instructions. Follow https://beignetjs.com/markdown/guides/background-jobs.md. First verify that the Projects tutorial, ownership rules, and SQLite setup are present. Preserve existing app customizations and follow installed package guidance if its version differs from the website. Generate projects.log-created, record the job in the same transaction as project creation, and add the database test for delivery, retry, validation, and rollback. Run the formatter and beignet check. Verify the new project over HTTP or in the browser, and inspect its outbox delivery. Explain any failed or skipped checks, the files changed, and how I can repeat the verification. ``` Continue with [Jobs](https://www.beignetjs.com/markdown/jobs.md) for payloads, dispatchers, and worker options, or return to [Guides](https://www.beignetjs.com/markdown/guides.md) to choose the next task.