Concepts
Payload-blind durable work scheduling and settlement core
Watchdog is a small durable job engine. You hand it jobs, it stores them in your
SQLite database, and each tick() claims one ready job, runs your executor and
records exactly one terminal outcome. If the process dies mid-job, the next tick
asks your liveness probe what happened and recovers the work.
Watchdog never looks inside a job. The payload, queue name and lane are opaque values you choose. Your code decides what a job means and how to run it. Watchdog owns the lifecycle: queued, running, settled. Claims, claim tokens and the SQL that moves rows between states stay private to the package.
Use it when you need work to survive a restart, run once per claim, and settle
with an honest outcome, without writing your own state machine. It runs on any
synchronous SQLite owner, such as a Cloudflare Durable Object’s storage or
node:sqlite, and on shared D1 or PostgreSQL databases.
Core concepts
Section titled “Core concepts”Jobs. A job has a jobId, a queue, a lane, an integer priority, an
opaque JSON payload and an optional recovery: { maxRecoveries }. IDs and
queue names are 1 to 256 characters; lanes are 1 to 512 characters. Owner-local
construction can reserve one lane for concurrent delivery; otherwise the lane is
stored without a scheduling policy. A job moves through three states:
queued: accepted and waiting.running: claimed by one tick and handed to your executor.settled: finished, with anoutcomeofcompleted,failed,cancelled,outcome_unknownorinterrupted, and an optionalfailureReason.
Ingest is idempotent. ingest(job) returns queued for a new ID and
duplicate for an identical replay. A replay with the same ID and different
queue, lane, priority, payload or recovery policy fails.
One tick, one job. tick() picks a ready queue head with the highest
priority, oldest first, across eligible queues. It claims that job, runs your
executor, and settles the exact claim it took. A second tick() on the same
runtime while one is in flight returns { status: "busy" } instead of running
in parallel. Your executor returns one of completed, failed (with an
optional short reason), cancelled or outcome_unknown. If it throws or is
interrupted, Watchdog settles the job as failed with its own reason code, such
as execution_failed or execution_interrupted.
Host ports. You supply four things:
owner: the SQLite capability shown in the quick start.execute(job, signal): runs one claimed job. Honour theAbortSignal.isAlive(job): answers whether a running job’s execution still exists.wake.recompute(): arms your next timer or alarm after durable state changes.
Disappearance recovery. Each tick first checks the oldest running claim. If
isAlive returns false, the execution is gone. A job with recovery budget
left goes back to queued. Otherwise it settles as interrupted. If isAlive
throws, Watchdog does not guess. The claim stays as it is, and an otherwise idle
tick reports { status: "liveness_unknown", jobId, reason }.
Cancellation. cancel({ jobId, cancellationId }) records a durable
cancellation. A queued job settles as cancelled on its next tick. A job the
current runtime is executing is interrupted and settles as cancelled.
Replaying the same cancellationId returns replayed. A different one returns
conflict. Pass onlyIfQueued: true to refuse cancellation once a job has
started.
Lane refusal. failLane({ lane, reason }) terminally settles queued and
running work in that lane as failed, retaining each job and its bounded owner
reason code. It handles at most 256 jobs per call, running jobs first, and
returns { settled, more }. Repeat only while more is true; 256 is a
transition batch, not a cap on accepted work. Already settled history and other
lanes stay intact. Once exhausted, another call returns
{ settled: 0, more: false } and fires no projector. A later enqueue is new
work, not a permanent refusal marker.
On owner-local SQLite, every changed job invokes project in the same
transaction as its settlement. A projector throw rolls back the entire batch and
all owner projection writes. A locally running executor is interrupted after
durable refusal; its tick still awaits cleanup and reports the committed failure
without projecting it again. Replaced durable claims cannot overwrite that
failure. A refusal does not undo provider effects already accepted before it.
The shared D1/PostgreSQL adapters expose the same bounded settlement method and
fence leases in one atomic SQL mutation. They do not offer the synchronous
SQLite-only project callback. Hosts still own authority and must stop revoked
execution; Watchdog owns durable job settlement, not App admission.
Owner transactions. runtime.transactional is a synchronous projection for
code already inside your own SQLite transaction. enqueueAcceptedJob and
recordCancellationIntent use the same mutation as ingest and cancel, so
your domain write and the job commit or roll back together. readQueueHead and
readLatestSettledJob read queue state inside that transaction.
Queue projection. Pass project to Watchdog.make to receive a
{ queue, head, settled? } transition inside the same transaction as every
ingest, claim, cancellation, settlement, lane refusal, recovery and purge. Use
it to keep a per-queue status row in step with Watchdog. A throw from project
fails the transition.
Purge. purge(queue) deletes every queued job in one queue. Running and
settled jobs stay.
Concurrent owner-local delivery
Section titled “Concurrent owner-local delivery”Owner-local construction may opt into one concurrentLane. Only the configured
runtime type adds tickConcurrent(): ordinary ticks exclude that lane and the
second tick selects only it. Both slots share one store and transactional
projection, with separate busy guards and active executions. Cancellation
signals the slot owning the job; recovery inspects only the idle slot’s
partition. Both slots require the queue head across lanes, ordered by running
first, then priority descending and enqueue sequence ascending. Cancelled queued
jobs can settle past a busy queue without executing.
The Effect runtime’s readSettled(limit, afterSequence) exposes the serial
partition in enqueue order. A prefix fence withholds later settlements until all
earlier serial jobs settle or are purged. Concurrent receipts never consume a
page slot; they remain readable by job ID. hasPendingWork still covers both
partitions. These owner-local operations require no stored schema change and do
not change the D1 or PostgreSQL adapters.
Shared D1 and PostgreSQL storage
Section titled “Shared D1 and PostgreSQL storage”@fungi.computer/watchdog/d1 exports createD1Watchdog and watchdogD1Schema.
@fungi.computer/watchdog/postgres exports createPostgresWatchdog and
watchdogPostgresSchema. Apply the package-owned schema through your migration
tool before you open a runtime. Opening a runtime never migrates a database.
These runtimes have the same ingest, read, readQueueHead, tick,
cancel, failLane, purge and hasPendingWork operations. They take
execute, wake and an optional now clock instead of an owner and liveness
probe. Watchdog answers liveness itself from the claim’s lease.
D1 enqueue(job) returns a prepared statement for the producer’s native batch.
enqueueFromSelect({ text, values }) accepts a trusted native SQLite
?NNN-bound SELECT of job_id, queue_name, lane, priority,
payload_json and nullable recovery_max. Watchdog supplies its INSERT and
conflict checks. This lets a job depend on facts conditionally inserted earlier
in the same batch, without a JavaScript read in between. PostgreSQL
enqueue(client, job) uses the caller’s checked-out transaction client. The
caller owns BEGIN, COMMIT and ROLLBACK. An identical replay does nothing. A
conflicting identity or cargo fails the whole native transaction.
Both adapters use the same lifecycle engine as the SQLite entrypoint. Claims
stay private, with two-minute leases, ten-second renewal and bounded renewal
I/O. Execution must honour its AbortSignal. Renewal and settlement reject
expired or replaced claims. A failed liveness probe never declares an execution
dead. Database time checks stop an in-flight renewal from resurrecting an
expired claim. The optional now clock replaces database time for deterministic
tests. Production hosts should use the default. A durable host wake still drives
ticks and disappearance recovery after a process dies. Provider effects remain
at-least-once.
purgeD1Queues and purgePostgresQueues return parameterized erasure
statements for queues selected by trusted producer SQL. The caller joins them to
its native batch or transaction, and Watchdog stays the only owner of job
deletion SQL. These helpers remove every attempt in the selected queues and
expose no claim or settlement controls. Carrier composes
them with envelope erasure.