Doc-status state machines
The problem: work that spans multiple actors — a user requests something, a server process picks it up, an external worker does a slow step, something posts the result. Traditionally: a queue, a consumer, a status API, and a dashboard to see what's stuck.
The pattern: one document per unit of work, with a status field walking
a small state machine. Every actor reads the statuses it owns, does its step,
and writes the doc forward.
The platform's own mention-builds bot
(the story) coordinates three actors this
way, through a mention doc in its requests database:
pending-build → building → built → replied
with terminals: skipped (guardrails), build-failed, error
- The vibe's
scheduledhandler ingests Bluesky mentions and writespending-builddocs (after guardrails — dedupe, caps, moderation). - A GitHub Actions cron picks up
pending-buildvia the CLI, flips it tobuilding, runs codegen, writesbuilt(orbuild-failed). - The next scheduled tick sees
built, posts the in-thread reply, writesrepliedwith the permalink.
Same shape in meta-hub's publisher:
publish-request docs walk pending → items-created → carousel-created → done | error, and you can inject work from anywhere the CLI runs:
shnpx vibes-diy db put --vibe you/your-hub --db requests '{
"kind": "publish-request", "platform": "ig", "slug": "my-post",
"images": ["https://…/slide-1.jpg"], "caption": "…",
"status": "pending", "attempts": 0, "createdAt": "2026-07-07T00:00:00Z"
}'
The result lands on the same doc a few ticks later — status, permalink,
or a lastError you can actually read.
Rules that keep it honest
- One writer per status. Decide which actor may write each transition
(the builder lane writes
building/built/build-failed; the vibe writes everything else) and enforce it inaccess.js, keyed on the status the document is leaving. The worked example below puts the whole transition table there. - Statuses are terminal or owned — never ambient. A worker that takes a
job writes down how long it owns it (a
leaseUntilon the doc). When the lease runs out, the job goes back to the pool for the next worker, bounded by anattemptscounter on the doc. Timeouts and retry budgets live on the document, visible to the dashboard. - Failures are states, not exceptions.
build-failed,error,skippedare first-class: nothing disappears, and the ledger doubles as the audit log. - Idempotent ids. Derive
_idfrom the upstream event (the Bluesky record key, the request slug) so re-ingesting is a no-op instead of a duplicate.
Jobs that start the moment they land: onChange plus a lease sweep
The examples above advance on scheduled ticks or an outside cron. When a
job should start as soon as someone queues it, let onChange do the work and
a scheduled sweep do the recovery:
pending → running → done
↑ │
└─────────┘ lease expired (sweep) running → failed after N attempts
- Every transition is read, then write with
ifRev. Each document a handler reads carries its revision as_rev. Writing withifRev: doc._revcommits only if nobody moved the job since that read; otherwise the write throwserr.code === "conflict"and the handler steps aside. The backend guide covers revisions. onChangeclaims and finishes. It acts only onpendingdocs and claims withifRev: event.doc._rev, so exactly one run gets each job. The claim writesrunningwith a freshclaimId, aleaseUntil, andattempts + 1. The run does the work, then writesdonecarrying the sameclaimId.- A
scheduledsweep recovers. Every tick it looks atrunningjobs; any whose lease has expired goes back topending(which firesonChangeagain), or tofailedonce it has used its attempts. It writes with the_revfrom its query, so it never overwrites a job that finished between the read and the write. access.jsholds the transition table, keyed onoldDoc.status, and names which handler may write each edge withctx.requireBackend(...).
access.js — the transition table
jsexport function jobs(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in" };
if (doc.type !== "job") throw { forbidden: "unknown document type" };
const out = { channels: ["jobs"], grant: { public: ["jobs"] } };
if (!oldDoc) {
if (doc.status !== "pending" || doc.attempts !== 0) throw { forbidden: "a new job starts pending with 0 attempts" };
return out;
}
// `input` is a string (the image URL). An object arrives as a fresh value on
// every read, so compare structured input field by field, never by reference.
if (doc.input !== oldDoc.input) throw { forbidden: "a job's input is fixed" };
const from = oldDoc.status;
const to = doc.status;
// Checked against the head this write replaces. The claim's `ifRev` makes it
// exclusive; `done` must still carry the claimId the doc holds now, because a
// slow run's lease can be taken back and the job claimed again while it works.
if (from === "pending" && to === "running") {
ctx.requireBackend("onChange");
if (doc.attempts !== (oldDoc.attempts || 0) + 1) throw { forbidden: "claim bumps attempts by one" };
if (!doc.claimId || doc.claimId === oldDoc.claimId) throw { forbidden: "claim needs a fresh claimId" };
return out;
}
if (from === "running" && to === "done") {
ctx.requireBackend("onChange");
if (doc.claimId !== oldDoc.claimId) throw { forbidden: "stale claim" };
if (doc.attempts !== oldDoc.attempts) throw { forbidden: "attempts fixed while running" };
return out;
}
if (from === "running" && to === "pending") {
ctx.requireBackend("scheduled");
if (!(oldDoc.leaseUntil < Date.now())) throw { forbidden: "lease still held" };
return out;
}
if ((from === "pending" || from === "running") && to === "failed") {
ctx.requireBackend("scheduled");
return out;
}
throw { forbidden: `no transition ${from} -> ${to}` };
}
backend.js — claim, work, finish, sweep
jsexport const config = { scheduled: { interval: "30s" } };
const DB = "jobs", LEASE_MS = 30_000, MAX_ATTEMPTS = 3;
const rid = () => Math.random().toString(36).slice(2, 10);
// One transition: commit `doc` only if the job is still at revision `ifRev`.
async function tryPut(ctx, doc, ifRev) {
try { await ctx.db.put(doc, { db: DB, id: doc._id, ifRev }); return { ok: true }; }
catch (err) {
if (err.code === "conflict") return { ok: false, reason: "someone else moved this job first" };
return { ok: false, reason: String(err?.message || err?.forbidden || err) };
}
}
export async function onChange(event, ctx) {
if (event.dbName !== DB || event.deleted) return;
const job = event.doc;
if (!job || job.type !== "job" || job.status !== "pending") return; // only pending jobs are work
const now = Date.now();
const claimed = { ...job, _id: event.docId, status: "running", attempts: (job.attempts || 0) + 1,
claimId: rid(), leaseUntil: now + LEASE_MS };
const claim = await tryPut(ctx, claimed, job._rev);
if (!claim.ok) {
ctx.log("info", "claim lost", { id: event.docId, reason: claim.reason });
return; // ack — another run has this job
}
const output = await resize(job.input, `${event.docId}:${claimed.attempts}`); // idempotent per jobId:attempt
const done = await tryPut(ctx, { ...claimed, status: "done", output });
if (!done.ok) ctx.log("warn", "done write refused", { id: event.docId, reason: done.reason });
}
export async function scheduled(event, ctx) {
ctx.log("debug", "sweep", { at: event.scheduledTime });
const now = Date.now();
let after;
do {
const page = await ctx.db.query({ db: DB, field: "status", key: "running", limit: 500, after });
for (const job of page) {
if (job.type !== "job" || !(job.leaseUntil < now)) continue;
await tryPut(ctx, { ...job, status: (job.attempts || 0) >= MAX_ATTEMPTS ? "failed" : "pending" }, job._rev);
}
after = page.next;
} while (after);
}
resize stands for your work. Four details carry the weight:
ifRevmakes the claim exclusive; theclaimIdkeeps the finish honest. TheoldDoccheck inaccess.jsalone is not a lock, so two claims could both commit. WithifRev: event.doc._revonly one does. Thedonewrite carries theclaimIdbecauseonChangecannot read the job back, and a slow run's lease may have been taken back and the job claimed again while it worked.- A lost claim returns; it doesn't throw. Throwing asks the platform to
redeliver, and the redelivered event carries the doc as it was when it
first fired —
pending, with the_revit had then. That claim conflicts, so returning is the quick way to the same place. - Make the work idempotent per
jobId:attempt. A job can run more than once — a raced claim, a lease that expired on a slow run — so key anything the work writes or sends by that pair. - The status guard makes writing back to the same database safe. A
backend write fires
onChangeagain, and the sweep's writes do too; the handler ignores anything that isn'tpending, so every chain ends after one step.
What we measured
We ran this shape on production as qa/job-state-machine on 2026-09-24, with a 5-second sweep, a 30-second lease, three attempts, and eleven jobs:
| What happened | What we saw |
|---|---|
| A 3-second job, start to finish | pending → running → done in 9.0–9.4 s; the commit reached onChange in 3.8–5.4 s |
| The handler threw after claiming | Redelivered ~11 s later (a 10 s backoff) with the original pending doc; the transition table refused it, and the sweep brought the job home — done about 41 s after the throw (lease + up to one tick + a re-claim) |
| The worker claimed and never finished | The sweep returned each attempt 4.0–4.9 s after its lease expired, and marked the job failed after three attempts (2 min 15 s) |
Two claims raced (before ifRev) |
Both claims committed; the loser's done was refused as a stale claim, so the job finished once. The loser's work still ran, and the job waited for the sweep. Claiming with ifRev refuses the second claim before any work starts |
| The backend's and the sweep's own writes | Each fired onChange again; the pending guard ended every chain |
The sweep ticked on its 5-second schedule exactly. Recovery takes about one lease plus one sweep interval, so pick both for how long a stuck job may sit: the example above uses a 30-second sweep.
Sizing onChange work
- Keep each run short, and chunk long work into
scheduled. The delivery queue gives a run 15 minutes, then delivers the event again while the first run may still be going — which a lease shorter than your work turns into a second worker. - Handlers run in batches of up to ten, in order, so one slow run delays the jobs queued behind it.
- Decide with the event you were handed. An
onChangerun can'tctx.db.geta database bound to an access function, so it claims with the event's own_rev— the conflict tells it another run won — and theclaimIdon thedonewrite is how a slow run finds out it lost its lease.
When a duplicate costs more than a gap
Retrying is the right default for work you can safely repeat. For a paid or externally visible action — a charge, a post, a message to a person — a second run is worse than a missing one. There, claim the unit of work first and skip rather than retry: a day the status doc names as missed beats a double charge nobody notices. The daily-digest example in the backend reference is that shape.
What you get for free
Because the "queue" is a Fireproof database: the dashboard is a
useLiveQuery over the same docs (see
the operator console pattern), every
transition syncs live to whoever's watching, the access function gates who
can inject work, and the CLI gives any script or CI runner authenticated
access with no extra API (the CI lane pattern).
Two actors never need to share a secret — they only share document shapes. The Bluesky credential in mention-hub never leaves the vibe's vault; the CI runner holds only a device cert. The database is the interface.