slothqueue 以租約佇列處理睡眠模式中斷的背景工作
I stopped losing background jobs to sleep mode: a patient queue with leases
作者以 slothqueue 建立本機背景工作佇列,透過租約在程序中斷後重新派發工作,並以延遲重試避免空轉。佇列使用單一 SQLite 檔案,九項行為測試覆核租約回收、退避重試及達到上限後轉為 failed。
I don't lose work to sleep mode anymore. Not because I built a smarter agent — because I finally wrote down what happens when a background job is interrupted.
Every "personal AI helper" demo has the same hole in it. The agent starts some long job (organising a folder, summarising a thread, checking a feed), you close the lid to go make coffee, and when you come back the thing is either still spinning, silently dead, or has run the same step four times. Nothing in the pipeline knows the difference between slow and gone.
slothqueue is my fix for that, and it is deliberately boring: one SQLite file, one process, no network calls, no broker, no cloud. Two ideas do all the work.
Idea 1: a running job is a lease, not a flag
The naive version sets status = 'running' and clears it at the end. Kill the process and the row stays running forever — the classic zombie job. The other naive version has no status at all, so a crash means the work is simply lost.
A lease fixes both:
job = q.claim("laptop:8123") # attempts += 1, lease_until = now + 60s
q.heartbeat(job.id, "laptop:8123") # extend while the work is genuinely going
q.complete(job.id, "moved 4 files")
If the process dies between claim and complete, nothing needs to notice. The lease simply expires, and the next claim() re-issues the job with attempts == 2 and a reclaimed event in its history. No reaper daemon, no cron job whose only purpose is to guess whether something is dead.
There's a second, subtler property here: ownership is part of the where-clause. A worker that lost its lease cannot complete the job it no longer holds:
UPDATE jobs SET lease_until = ? WHERE id = ? AND state = 'running' AND lease_owner = ?
If that updates zero rows, the worker knows it was replaced. That single check is what stops the "two workers, one job, doubled side effects" bug that shows up the moment a job runs longer than expected.
Idea 2: waiting should cost nothing
The lazy version of retries is a loop. while True: try; sleep(1). On a laptop that is a fan spinning at 3 a.m., and it burns battery to accomplish nothing.
Here failure reschedules into the future instead of retrying now:
fail -> not_before = now + 2**attempts (capped)
and claim() only ever looks at jobs whose not_before has passed. When nothing is due, claim() returns None, the worker exits, and the machine actually goes idle. That is the whole "sloth" idea: correctness by not doing things, not by doing them faster.
The honest failure mode matters too. When attempts reaches max_attempts, the job goes to failed with the last error attached. It does not retry forever, and it does not pretend to be pending. You can read the story:
$ python -m slothqueue.cli --db demo/demo.db status
{'pending': 1, 'running': 0, 'done': 1, 'failed': 0, 'due_now': 0}
$ python -m slothqueue.cli --db demo/demo.db work --dir demo/jobs --once
ok tidy_downloads (403e48fb481d) tidy: moved 4 files
fail disk_check (d6a6528950ad) -> pending: disk check: 82% full
One job done, one failed and backed off with a real delay — not a spin.
What I refused to add
The temptation with anything "AI-powered" is to bolt a model onto it. I didn't, and I think that's the more useful position: the model isn't the unreliable part of a personal agent. The unreliable part is the waiting — the crash, the sleep, the second run of a step that already happened. Fix that and the model becomes a normal component you can reason about.
So the scope stays small on purpose:
- no distributed workers (a laptop doesn't need them — run two queues in two folders if you want two lanes);
- no parallelism (concurrency is what makes local background work noisy);
- no dependencies at all —
sqlite3andsubprocessfrom the standard library.
Proof instead of promises
The part I'd want to see in someone else's post is whether the recovery story is actually tested rather than asserted. There are nine behavioural tests with an injected clock, so lease expiry and backoff are verified without sleeping:
- a delayed job is not claimed early;
- a dead worker's lease is recovered with
attempts == 2and the event trailpushed, claimed, reclaimed, claimed; - failure schedules
2, 4, 8seconds, capped, then lands infailed; - a heartbeat from a non-owner returns
Falseand cannot steal the lease; - a lease that expires after
max_attemptsgoes tofailed, not back to pending.
python -m unittest discover -s tests -v # 9 tests, ~0.3s
Code: https://github.com/ciperx/slothqueue (MIT)
If you take one thing from this: the thing worth engineering in a personal agent is not the intelligence, it's the bookkeeping around interruption. Slow is fine. Gone is not.
來源:DEV Community · dev.to