Docs menu
Background work: queue, scheduler & events
Run agent work outside a request — durable jobs, cron triggers and an event bus, each exactly-once at its own boundary, all on the same journal.
What it is for#
Not every agent run starts with an HTTP request. Something has to process a backlog, fire at 03:00, or react to an event — and each of those is a place where a crash can duplicate a side effect. These three packages put the same guarantee at those three boundaries.
None of them reimplements durability. A job handler typically calls runDurable, a trigger runs a durable workflow, and an event consumer is expected to do the same. What each package adds is the piece the journal alone cannot give: a lock so two workers do not take the same job, a fire record so a cron slot is not run twice, and an ack marker so an event is not marked done before it is.
Queue — jobs that survive the worker#
enqueue writes the job, createWorker claims and runs it. A worker holds a lock with a TTL; if it crashes, the lock goes stale, another worker reclaims the job, the handler runs again — and because the handler resumes the same runId, the side effect still happens once. Failure is bounded by maxAttempts, after which the job becomes dead-letter and can be revived with retryJob.
Empty-poll backoff is on by default: when a poll claims nothing, the interval doubles up to a cap and resets the moment a job is claimed. That default exists because an audit found the alternative — a thousand consumers polling an empty queue at a constant interval produces tens of thousands of queries a second.
import { enqueue, createWorker } from '@gnldev/queue';
await enqueue(storage.work, 'reindex', { docId: 'd-9' });
// one handler per job type; ctx carries the job's own durable runId and journal
const worker = createWorker(
storage,
{
reindex: async (payload, ctx) => {
// a reclaim resumes this runId, it does not restart it
await runDurable({ runId: ctx.runId, journal: ctx.journal, model, tools, prompt: payload.docId });
},
},
{ ttlMs: 30_000, maxAttempts: 3 },
);
worker.start();Scheduler — cron, without double fires#
scheduleWorkflow registers a trigger: a one-shot at, a periodic every, or a 5-field cron (UTC, minute resolution). The trigger definition is immutable in the journal and only its state moves. Each fire takes a run lock and derives its runId from the fire count, so the same slot cannot start twice.
Time is treated as data: the next run time is resolved and then frozen into the journal, which is what keeps a replay from drifting with the wall clock.
misfire decides what a missed window means. 'skip' (the default) jumps to the next slot on the planned grid so no drift accumulates; 'catchup' fires each missed occurrence in turn, one per poll. A nightly report usually wants skip; a billing cycle usually wants catchup.
import { scheduleWorkflow, createScheduler } from '@gnldev/scheduler';
await scheduleWorkflow(journal, {
name: 'nightly-report',
cron: '0 3 * * *', // 03:00 UTC
misfire: 'skip', // or 'catchup' to replay every missed slot
});
createScheduler(journal, runner).start();Events — fan-out with an honest guarantee#
emit appends to a log; each consumer processes events under its own ack marker, so N consumers each see every event. The marker is written with a CAS after the handler succeeds. That ordering is the whole design: if the handler throws or the process dies, the event is not lost and the next poll retries it.
The cost is stated rather than hidden: this is exactly-once marking with at-least-once delivery. A crash between a successful handler and its ack — or a concurrent poll race — can redeliver. Write the handler idempotently, or put runDurable / claim inside it, and the redelivery costs nothing.
import { emit, createConsumer } from '@gnldev/events';
await emit(storage.work, 'order.paid', { orderId: 'o-7' });
const consumer = createConsumer(
storage.work,
'order.paid',
async (payload, meta) => {
// the ack marker is written only after this returns — so make it idempotent
await runDurable({ runId: `evt:${meta.id}`, journal, model, tools, prompt: payload.orderId });
},
{ name: 'fulfilment' }, // ack markers are separated by this name (fan-out)
);
consumer.start();One failing handler no longer holds the topic#
A consumer's cursor only advances past an event its handler accepted. So a handler that keeps throwing used to park the cursor forever: every event behind the failing one waited on a delivery that was never going to succeed. Not a lost message — a stopped topic.
A delivery is now quarantined after maxAttempts failures (default 8) and the cursor moves on. The attempts are spaced by retryDelayMs — exponential from 60s, doubling, capped at an hour — so the budget spans about two hours rather than burning out in a second at the poll interval. A retry budget is only a budget if it outlasts the kind of outage it exists for.
A not-yet-due event is skipped but still freezes the cursor: it is retryable, so nothing behind it may be marked as passed. Waiting is not giving up. maxAttempts: Infinity keeps that shape forever if you would rather never quarantine — other events still flow, but this consumer stays parked and re-scans from that point on every poll.
import { createConsumer, listDeadEvents, retryDeadEvent } from '@gnldev/events';
const consumer = createConsumer(storage.work, 'order.paid', handler, {
name: 'fulfilment',
maxAttempts: 8, // quarantine after this many FAILED attempts (the default)
retryDelayMs: 60_000, // fixed spacing; omit for exponential 60s → 1h (the default)
});
// what is parked, and why
const dead = await listDeadEvents(storage.work, 'order.paid', 'fulfilment');
// [{ id: 'e-3', attempts: 8, error: 'ECONNREFUSED', at: 173... }]
// hand one back once the downstream is healthy again
await retryDeadEvent(storage.work, 'order.paid', 'fulfilment', 'e-3');listDeadEvents(work, topic, consumer) shows what is parked and retryDeadEvent(work, topic, consumer, eventId) hands one back. Studio surfaces both, so an operator does not need a script. Addressing is by the triple (topic, consumer, id) — a topic fans out, so one event has one quarantine record per consumer and an id alone names several of them.
API#
enqueueWrites a job to the WorkStore. QueueDepthExceededError guards against an unbounded backlog.
createWorkerClaims and runs jobs. Options: owner, ttlMs (stale-lock reclaim), pollMs, maxAttempts (dead-letter threshold), backoff, maxPollMs.
listJobsJob status list — including dead-lettered ones.
retryJobRevives a dead-lettered job.
scheduleWorkflowRegisters a trigger: { name, input?, at | every | cron, maxAttempts?, misfire? }.
pollSchedulerFires the triggers that are due; each fire is exactly-once via a run lock and a fire-count runId. lockTtlMs (default 60s) bounds how long a crashed poller blocks the trigger — the holder renews the lock while it works, so recovery is the TTL rather than the slowest run.
listTriggersRegistered triggers with their state, including the last error.
createSchedulerA start/stop poll loop around pollScheduler; takes the same lockTtlMs.
emitAppends an event to the log. EventDepthExceededError guards the backlog.
createConsumerA named consumer with its own ack markers; the marker is written only after the handler succeeds. Options: pollMs, backoff, maxPollMs, maxAttempts (quarantine threshold, default 8), retryDelayMs (spacing between attempts, exponential from 60s by default).
listDeadEventsQuarantined deliveries for one (topic, consumer) pair, with attempt count and the last handler error.
retryDeadEventHands one quarantined delivery back to the consumer. Idempotent: releasing an already-released event is a documented operation, not an error.
runDurable with a stable runId derived from the job or event id, and the completed tools inside it will not run again. This is the same guarantee the HTTP path gets, reached from a different entry point.enqueue and emit can refuse when the backlog is too deep (QueueDepthExceededError, EventDepthExceededError). An unbounded queue turns a slow consumer into an outage that is discovered when storage fills up; a refused write is discovered immediately.