@shiit/watchdog
v1.0.4
Published
The dog that hunts — a generic Oban-style durable job engine for Cloudflare Durable Objects
Maintainers
Readme
@shiit/watchdog
Beware of dog! Let it crash, the dog will guard the yard.
A durable job engine for Cloudflare Durable Objects. Oban for DOs. One class, a dozen methods, zero dependencies.
What it's for
Work that has to outlive the request. You hand the dog a job, it keeps the books: queued, executing, settled. If your DO gets evicted mid-job (and it will, eventually), the dog notices and you decide what happens next. Nothing just disappears.
The job row is the job. payload is opaque: the engine stores it, returns it on claim, hands it back on settle, and never reads it. Not even in the sweep. Your jobs, your meaning.
Let's go
npm i @shiit/watchdogMake a dog in your Durable Object:
import { Watchdog } from "@shiit/watchdog";
export class MyDO {
dog: Watchdog;
constructor(state: DurableObjectState) {
this.dog = new Watchdog({
storage: state.storage,
getNodeId: () => state.id.toString(),
requestWake: () => state.storage.setAlarm(Date.now() + 1000),
isAlive: async (job) => this.isJobRunning(job),
onClaimed: async (job) => this.runJob(job),
onSettled: async (job, outcome) => this.recordIt(job, outcome),
});
}
async alarm() {
await this.dog.tick();
}
}That's the whole setup. Now the lifecycle:
// Put work in the yard
await dog.ingest({
jobId: "email-123",
queue: "emails",
payload: { to: "[email protected]" },
});
// Pull work out (when you're ready to run it)
const job = await dog.claim("emails"); // null if the cap is busy or the queue is empty
// Finish it
await dog.complete(job.jobId, { outcome: "completed" });And the alarm keeps everything moving: tick() sweeps stale work and claims queued work for idle queues. Point your DO's alarm() at it and forget it exists.
Things worth knowing
Retry is opt-in. Default is never. Every job system ships a silent default of "run it again." Double-sent emails are born there. So the dog asks at the door: what happens if this runs twice? No retry declaration, no re-drive. A crash settles the job interrupted and your onSettled hears about it. Loud over silent. If you want retries, say so: ingest({ ..., retry: { maxAttempts: 10 } }) and only genuinely transient errors (429s, 5xx, network hiccups) will re-queue with backoff. Permanent errors settle immediately even then.
The sweep asks, never guesses. Before the dog settles a job that looks dead, it calls your isAlive(job). You define alive. For long-running work, read your worker's state. (Our agent harness answers two questions: is the session still streaming, and has it done anything in the last 30 minutes. A live-but-silent run past that bound is a wedge, and it gets buried. Everything else gets left alone.) No clocks that convict. Timers schedule looks; verdicts come from the owner or from boot evidence.
Quiet jobs stay warm. DOs unload when they go quiet, and a long job with a quiet stretch would die mid-run. So the dog arms a 30-second alarm while a job executes. Each wake keeps the isolate warm and runs the sweep. complete disarms it. No executing row, no wake: the alarm is recomputed from state every time and never develops its own momentum.
Priority is the only ordering knob. Higher priority first, then oldest first. That's the whole ordering story. Our agent product's convention, if you want one: 0 interactive work, -1 cron, 10 mid-run steers.
The whole surface
dog.ingest({ jobId, queue, payload, priority?, retry? });
dog.claim(queue); // -> JobRow | null
dog.complete(jobId, { outcome, result? });
dog.tick();
dog.abort(queue); // settles the executing job as cancelled
dog.purge(queue);
dog.queuePosition(queue);
dog.nextWakeNeeded(now); // for your alarm schedulingOne DO, one engine, one executing job by default (maxConcurrency if you want more).
What's next
Want agent mail on top? @shiit/bird-dog: this engine plus the pigeon (the mail layer), composed. The dog finds the letters even in the hand of a dead pigeon.
Part of the shiit stack. MIT.
