alepha@docs:~/docs/guides/server$
cat 12-background-jobs.md | pretty
6 min read
Last commit:

#Background Jobs

$job is the primitive for work that happens outside a request. It is backed by a database table (the outbox), which is what makes it durable: a push writes a row before anything runs, so a handler that throws — or a process that dies mid-flight — leaves a record that a reconciliation sweep picks back up.

typescript
1import { $job } from "alepha/api/jobs";

$job lives under alepha/api/ because it needs an ORM connection and ships an admin controller. Register the module alongside your other API modules:

typescript
1import { AlephaApiJobs } from "alepha/api/jobs";2 3alepha.with(AlephaApiJobs);

#Two modes, never both

A job declares either schema (queue-mode, push-driven) or cron (cron-mode, schedule-driven). Declaring both is a configuration error.

typescript
 1import { z } from "alepha"; 2import { $job } from "alepha/api/jobs"; 3  4class Emails { 5  // queue-mode — call push() to enqueue work 6  welcome = $job({ 7    schema: z.object({ userId: z.text() }), 8    retry: { retries: 3 }, 9    handler: async ({ payload, attempt }) => {10      // send the welcome email for payload.userId11    },12  });13 14  // cron-mode — fires on a schedule, no payload15  digest = $job({16    cron: "0 8 * * *",17    handler: async () => {18      // build and send the daily digest19    },20  });21}

To run scheduled work over a set of payloads, compose the two: a cron job that pushes, and a queue job that handles.

typescript
 1class Reminders { 2  sweep = $job({ 3    cron: "0 * * * *", 4    handler: async () => { 5      const due = await this.repository.findMany({ where: { due: true } }); 6      await this.remind.pushMany(due.map((row) => ({ payload: { id: row.id } }))); 7    }, 8  }); 9 10  remind = $job({11    schema: z.object({ id: z.text() }),12    handler: async ({ payload }) => { /* ... */ },13  });14}

#Pushing work

typescript
1const executionId = await this.welcome.push({ userId: "u1" });

push() accepts a second options argument:

Option Type Description
delay DurationLike Run no earlier than now + delay
scheduledAt Date Run no earlier than this instant
key string Deduplication key — see the caveat below
priority "critical" | "high" | "normal" | "low" Sweep dispatch order when there is a backlog
organizationId string Owning tenant, persisted on the row for tenant-scoped admin views

pushMany() takes an array of { payload, key?, delay?, priority?, scheduledAt? } and writes them in a batched INSERT.

#key dedups in-flight work, not completed work

A push with a key returns the existing execution id instead of enqueueing a second row — but only while a row with that key still exists. On success the row is either deleted (record: "error", the queue-mode default) or updated with key set to null. Either way the key is released once the job succeeds.

So key means "don't enqueue this twice while it's still pending, running, or failing" — it is not "run this at most once ever". If you need the stronger guarantee, enforce it in the handler against your own data.

#Retries

Set retry: { retries: n }, optionally with when: (error) => boolean to retry only certain failures. Retries are sweep-driven and have no exponential backoff: a failed attempt is rescheduled with scheduledAt = now, and the next sweep tick picks it up.

That means retry latency is bounded by sweepCron (default */15 * * * *). The first retry lands anywhere from a few seconds to ~15 minutes later, depending on where the tick falls. Lower sweepCron if you need tighter latency.

Cron-mode jobs without retry do not retry — the next tick is the retry. Cron jobs that declare retry go through the same sweep path, which is useful for once-daily jobs where waiting a full day is not acceptable.

#Timeouts and cancellation

timeout caps a single attempt. The handler receives an AbortSignal — pass it to anything that supports one, since Alepha cannot interrupt synchronous work:

typescript
1report = $job({2  schema: z.object({ id: z.text() }),3  timeout: [30, "seconds"],4  handler: async ({ payload, signal }) => {5    await fetch(`https://example.com/${payload.id}`, { signal });6  },7});

await job.cancel(executionId) cancels a pending or running execution.

#Dispatch modes

How a pushed execution reaches its handler depends on which modules are loaded:

  • direct (default) — the handler runs in-process right after push() returns. The outbox row is the durability guarantee: if the process dies, the sweep re-dispatches. Best for single-instance Node and Cloudflare Workers, where standing up a broker is overkill.
  • queue — add AlephaApiJobsQueue and dispatch goes through a real broker (Cloudflare Queues, Redis) so a worker pool consumes the work.
typescript
1import { AlephaApiJobs, AlephaApiJobsQueue } from "alepha/api/jobs";2 3alepha.with(AlephaApiJobs).with(AlephaApiJobsQueue);

Both modes are at-least-once. Write handlers to be idempotent.

#Retention

Queue-mode jobs default to record: "error" — the pending row is written at push time and removed on success, so a healthy queue leaves no rows behind. Cron jobs default to record: "all" with one retained success so the admin "Last run" column is accurate.

Setting Effect
record: "error" Keep error and cancelled rows only (queue default)
record: "all" Keep successes too, trimmed to keepLastSuccess
record: "none" Fire-and-forget, no row even on error
keep: { ok, error } Per-job override. 0 here means keep forever

Note the deliberate asymmetry: per-job keep.ok: 0 means never trim, while the global keepLastSuccess: 0 means delete on success.

#Configuration

Tune the jobConfig atom:

typescript
1import { jobConfig } from "alepha/api/jobs";2 3alepha.set(jobConfig, { sweepCron: "*/5 * * * *" });
Key Default Description
sweepCron */15 * * * * Reconciliation sweep — bounds retry latency
trimCron 0 * * * * Ring-buffer trim tick
staleThreshold 300000 Pending age (ms) before the sweep re-dispatches
runTimeout 1800000 Running age (ms) before a crash is assumed
keepLastSuccess 10 Successful rows kept per job
keepLastError 10 Error rows kept per job
drainTimeout 30000 Time (ms) to wait for in-flight jobs on shutdown

#Multi-replica deployments

Cron-mode jobs take a distributed lock per tick (lock: true by default), so a fleet of replicas fires the handler once, not once per replica. This needs a real LockProvider — the default MemoryLockProvider is per-process. See Bare metal deployment for the setup.

lock has no effect on queue-mode and direct-mode jobs. Those serialize through the outbox claim() UPDATE-guard instead, which is always on.

#Events

$job emits lifecycle events you can hook:

Event Payload
job:begin { name, now, executionId }
job:success { name, executionId }
job:error { name, error, executionId }
job:cancel { name, executionId }
job:end { name, executionId }

#When not to use $job

  • A bare periodic tick with no database. $job needs an ORM connection. For a tick without one, register directly against the cron engine:

    typescript
     1import { CronProvider } from "alepha/scheduler"; 2  3class Ticker { 4  protected readonly cron = $inject(CronProvider); 5  6  protected readonly setup = $hook({ 7    on: "start", 8    handler: () => { 9      this.cron.createCronJob("revalidate", "0 * * * *", async () => {10        // ...11      });12    },13  });14}

    You get the tick and the distributed lock, but no run history, no retry and nothing in the admin UI. Reach for it only when a database is genuinely unavailable.

  • Fan-out to many subscribers. Use $topic / $subscriber, which is publish/subscribe rather than work distribution.