nobroker/
A durable job queue for Python

The job queue with nobody in the middle.

Enqueue work, lease it, ack it. It survives kill -9, it is safe across processes, and it needs no Redis, no RabbitMQ, no server, and no dependencies at all — just the standard library and a file.

0dependencies
124tests passing
1file on disk
3.11+python
jobs/emails.000001.logappending
Every record is length-prefixed and CRC-checksummed.
the model what a broker actually costs you

Same guarantee. One fewer moving part.

A broker gives you three things: durable storage, a serialisation point, and a network endpoint. On a single machine you do not need the third — and the operating system already gives you the first two.

TODAY — CELERY, RQ, DRAMATIQ worker 1 worker 2 worker 3 network boundary TCP broker a process you run disk + a server to deploy + a server to monitor + a server to keep alive NOBROKER worker 1 worker 2 worker 3 flock() write() fsync() emails.log append-only one file disk no network hop no serialisation to a wire format nothing to deploy
The only structural difference is the middle column. The kernel already serialises the writers (flock) and already makes the bytes durable (fsync) — the broker is re-implementing both, on the far side of a socket.
OptionWhat you installWhat you operate
Celerycelery, kombu, billiard, vine, amqp…a Redis or RabbitMQ server
RQrq, redisa Redis server
Dramatiqdramatiq, pika or redisa broker
nobrokernothingnothing

It is not a Celery replacement for a fleet

The lock is a kernel file lock. It does not work over NFS, and it will not coordinate two hosts. There is no distributed mode and there will not be one.

the model what happens to a job

Four states, and every edge is a record on disk

READY available_at ≤ now LEASED invisible for 30s DONE compaction drops it DEAD the DLQ keeps it lease ack nack — or the lease expires retry after backoff attempts exhausted requeue_dead() DELAYED is not a state — a delayed job is READY with available_at in the future
Attempts are counted on lease, not on completion, so a worker that dies silently still burns one. Every edge here appends a record; nothing changes in memory that did not reach the disk first.
the model what a crash actually looks like

A torn tail is expected, not exceptional

Kill a process mid-write and you get half a record. The length prefix catches an incomplete one; the checksum catches the nastier case — a full-length record with a hole in the middle, which would otherwise be applied silently.

Recovery truncates back to the last record that checksums, and reports what it discarded. The interrupted operation is simply one whose caller never got a return value.

Tested exhaustively, not anecdotally

The suite takes a real log and truncates it at every single byte offset, asserting recovery lands somewhere consistent at each. A crash can only interrupt a write at a byte boundary — so correctness at all ~1,300 boundaries is correctness for any crash that log could have suffered.

ONE RECORD ON DISK length type crc32 payload — JSON, n bytes uint32 uint8 uint32 {"id":"a81eff54","payload":{…}} 9-byte frame + payload THE SAME RECORD, INTERRUPTED BY kill -9 length type crc32 41 bytes ← end of file length says 299 · only 41 arrived · truncate here and carry on
The frame is 16 bytes of file header, then this, repeated. Payloads are JSON on purpose: a durability format you cannot read with your eyes is one you cannot debug.
the model every operation, start to finish

Lock. Catch up. Reap. Append, fsync, apply.

Beat two is what makes several processes safe with no coordinator: a worker that has been idle for an hour just reads forward from its own offset and finds out what happened.

BEAT 01

Take the file lock

The kernel arbitrates, and releases it automatically when a process dies — including on SIGKILL. No pid file, no stale-lock heuristic.

BEAT 02

Catch up

Read forward from our last offset, applying whatever peers appended. No heartbeats, no gossip, no notification channel.

BEAT 03

Reap expired leases

A worker past its deadline is either dead or wedged. Both get the same treatment: back to READY, with backoff.

BEAT 04

Append, fsync, apply

Disk before memory, always. A crash between the two replays to the same state; the other order loses work the caller was told had succeeded.

flock scan reap write fsync apply + unlock exclusive from our offset expired leases append record the commit point memory changes crash in here → torn tail, discarded crash after here → the record replays, same state
enqueue() returns only once the write is past fsync. If it returned, the job is on the platter — not in a page cache waiting for a power cut.
the guarantee what is actually promised

At-least-once. Never exactly-once.

Your handlers must be idempotent

A worker can be killed after its handler finished but before it acked. The lease expires, the job comes back, the email sends twice. No engineering closes that window, because the side effect and the acknowledgement live in different systems.

Exactly-once would need the handler's side effect and the queue's acknowledgement to commit in a single transaction — which means the queue must live inside your database, and then it is not a general-purpose queue any more. Anything advertising exactly-once is either doing that, or redefining the term.

Fencing tokens

Every lease carries a token. If yours expired and the job was redelivered, your ack() is rejected rather than silently completing someone else's delivery. You find out.

Lease heartbeats

The worker extends the lease of a running handler, so slow-but-healthy is never a source of duplicate work. Only real failures redeliver.

Idempotent enqueue

job_id="order-42" de-duplicates on the key. The one place exactly-once is honestly available, because that is something a log can actually do.

the evidence 124 tests, no plugins

Two of these found bugs nothing else would have

The multi-process test caught a stale cached file offset that made peers overwrite each other's records — four producers writing 160 jobs produced 35. The compaction test caught os.open defaulting to text mode on Windows, silently rewriting every 0x0A in a checksummed binary log.

Both were invisible to single-process happy-path testing. The 51-test queue suite passed throughout.

Read the design notes
Test fileTestsWhat it is for
test_crash_recovery11Hard kills, torn tails, bit flips
test_queue39Leases, priorities, retries, DLQ, compaction
test_cli21Every subcommand, in-process
test_codec17Framing; flips every bit of a record
test_logfile14Log mechanics, generation layout
test_concurrency8Four separate OS processes racing
test_worker8Outcomes, shutdown, heartbeat
test_backoff8Delay policy

Crash it yourself.

The playground runs the same record format in your browser — real CRC-32, real byte offsets. Interrupt a write mid-record and watch recovery find the tear.

Open the playground
quickstart ten minutes, from nothing

Quickstart

Start here even if you have never used a job queue. Every step below is runnable, and each one says what just happened.

What this is for

Some work should not happen while a user waits: sending an email, resizing a photo, calling somebody else's API that is down half the time. So you write the work down and answer immediately, and a second program picks it up and does it.

That written-down list is a job queue. The hard part is not the list — it is what happens when a program dies holding a job, when the work fails, or when the power goes. nobroker is that list, kept in a file, with those cases handled.

What makes it unusual: there is no server to run. Normally a queue means installing Celery and operating a Redis box. Here there is a file, and nothing else.

Step 1 — get it

You need Python 3.11 or newer, and nothing else at all. Check what you have:

python --version
# Python 3.11.9   -- 3.11 or higher is fine

Then pick whichever suits you:

# the normal way
pip install nobroker

# or, with no install at all: copy the folder into your project
cp -r src/nobroker /path/to/your/project/

# or run it as one file, no install, from anywhere
python make.py build
python dist/nobroker.pyz --help

Why copying the folder is allowed

nobroker imports nothing but the Python standard library, so there are no dependencies to bring along. That is unusual, and it is the point of the project — dropping the folder in works exactly as well as installing it.

Step 2 — your first queue

Save this as first.py and run it. It is complete: nothing is left out, nothing needs to be running.

from nobroker import Queue

# A queue is a directory. It is created if it does not exist.
q = Queue("./jobs")

# Write down some work. The payload is any JSON-able value -- nobroker
# never looks inside it, it just keeps it safe and hands it back.
q.enqueue({"send_email_to": "ada@example.com"})
q.enqueue({"send_email_to": "grace@example.com"})

print(q.stats())
q.close()
$ python first.py
QueueStats(ready=2, delayed=0, leased=0, done=0, dead=0, total=2)

What just happened

Two jobs are now on disk. Not "scheduled to be written" — actually on the physical disk, because enqueue() does not return until the operating system confirms it. Unplug the machine right now and both are still there.

Run the script again and you will see total=4: the queue picked up where it left off, because the directory is the queue.

Step 3 — actually do the work

Now the other half: something that takes jobs off the list and runs them. Save this as work.py.

from nobroker import Queue, Worker

q = Queue("./jobs")

def handle(job):
    # Your code. Returning = success. Raising = failure, try again later.
    print("sending to", job.payload["send_email_to"])

# idle_timeout=1 makes it exit once the queue has been empty for a second.
# Leave it out and it runs forever, which is what you want in production.
Worker(q, handle, idle_timeout=1).run()
$ python work.py
sending to ada@example.com
sending to grace@example.com

Those two scripts are the whole idea. first.py is your web app; work.py is your background worker. They are separate programs, they can run on separate terminals at the same time, and the only thing between them is the ./jobs directory.

Try this

Open two terminals. Run work.py without idle_timeout in one, and first.py repeatedly in the other. The worker picks up new jobs within about a tenth of a second. Now press Ctrl-C on the worker mid-run: it finishes the job it is holding before exiting, and nothing is lost.

Step 4 — what happens when work fails

This is the part you are really buying. Make the handler fail:

from nobroker import Queue, Worker

q = Queue("./jobs", max_attempts=3)   # give up after 3 tries
q.enqueue({"charge": 500})

def handle(job):
    raise RuntimeError("the payment API is down")

Worker(q, handle, idle_timeout=1).run()

for job in q.dlq():                     # the jobs it gave up on
    print(job.id, job.attempts, job.last_error)

Nothing crashes and nothing is lost. The job is retried after 1 second, then 2, then 4 — waiting longer each time, so a service that is already struggling is not hammered. After three tries it is moved to the dead-letter queue: a holding area for work that will not succeed, with the error text kept so a person can look at it. Fix the bug, then put them all back with q.requeue_dead().

The one rule you must know

A job can run more than once. If a worker sends the email and is killed before it can record that it did, the job comes back and the email is sent twice. No queue anywhere avoids this — see Objections for why.

So write handlers that are safe to run twice. Usually that means checking before acting ("has order 42 already been charged?"), which is one if statement and turns a rare duplicate into a non-event.

Step 5 — look at what is on disk

There is no magic here, and you can check that:

$ ls jobs/
default.000000.log   default.lock

$ nobroker --dir ./jobs inspect
   16  ENQUEUE  {"id": "a3f1...", "payload": {"send_email_to": "ada@..."}}
  184  LEASE    {"id": "a3f1...", "deadline": 1735689630.2}
  246  ACK      {"id": "a3f1...", "at": 1735689601.8}

One file, appended to and never edited. Every line is a thing that happened, in order. The queue's state is that file replayed from the top — which is why a crash can only ever lose the record that was being written, and why you can read your own queue with your eyes when something goes wrong at 3am.

Step 6 — from the terminal

A queue you cannot inspect from a terminal is a queue you cannot operate. Every command takes --dir to say which queue directory it means.

nobroker enqueue '{"resize": "photo.jpg"}' --priority 5
nobroker stats                  # how many in each state
nobroker list --state ready     # what is waiting
nobroker work myapp.tasks:handle --concurrency 4   # run a worker
nobroker dlq                    # what failed, and why
nobroker dlq --requeue          # after you fix the bug
nobroker inspect                # the raw log, record by record
nobroker recover                # report on torn-tail repair
nobroker compact                # drop completed history, shrink the file

nobroker work myapp.tasks:handle is worth noticing: a worker is a command, not a program you have to write. The argument is module:function.

Where to go next

Learn the ideas

The Guide covers leases, retries, priorities, several workers at once, and compaction — in the order you meet them.

Look things up

The API reference lists every method, argument and default, with an example for each group.

Or try it with nothing installed

The playground runs the same state machine in your browser. Enqueue jobs, let a lease expire, crash a write halfway through a record, and watch recovery find the tear. It has a guided tour built in.

No make? (Windows)

Every task also runs through make.py, which needs only Python:

python make.py demo         # the end-to-end tour
python make.py test         # the test suite
python make.py bench        # throughput on your machine
python make.py check-deps   # prove there are no dependencies
guide concepts, in the order you meet them

Guide

Everything nobroker does, why each piece is shaped that way, and what goes wrong if you set it badly. Read it in order the first time.

The six words you need

Every queue uses these, and they are the only vocabulary in this guide. If you have read the Quickstart you have already used four of them.

WordMeans
jobOne piece of work to do later, plus your data for it.
enqueueWrite a job down. Returns once it is safely on disk.
leaseClaim a job to work on — without removing it from the queue.
ack"Finished it." Only now does the job leave the queue.
nack"That failed." Put it back and try again after a wait.
DLQDead-letter queue: where a job lands after failing too many times.

The one that is not obvious is lease, and it is the reason a queue is more than a list. Keep reading.

Jobs and payloads

A job is your data — the payload — plus everything the queue knows about how its delivery has gone: how many times it has been tried, when it may next run, what went wrong last time. nobroker never looks inside the payload; it is yours.

job = q.enqueue(
    {"resize": "photo.jpg", "width": 1024},
    priority=5,          # higher leases first; ties break FIFO
    delay=30.0,          # not eligible for 30 seconds
    max_attempts=3,      # then the dead-letter queue
    job_id="order-42",   # optional: de-duplicates on this key
)

Every argument above is optional; only the payload is required.

When enqueue() returns, the job is on disk — it fsyncs before returning. Payloads must be JSON-serialisable: dicts, lists, strings, numbers, booleans, None. No bytes, no class instances, no datetime. That is the price of a log you can read with your own eyes when something has gone wrong.

job_id: the argument worth knowing about

Pass your own id and enqueueing twice with it creates one job:

q.enqueue({"charge": 500}, job_id="order-42")   # creates it
q.enqueue({"charge": 500}, job_id="order-42")   # does nothing

So a user double-clicking "Pay", or an HTTP request your client retried, cannot schedule the work twice. Use a natural id from your own data — an order number, a user id plus a date — rather than a random one.

Leases and the visibility timeout

Why not just remove a job when a worker takes it? Because then a worker that dies mid-job takes the job with it. It is not in the queue, it was never finished, and nobody knows it existed. That is the failure a queue is supposed to prevent.

So a worker borrows the job instead. Leasing it makes it invisible to every other worker for visibility_timeout seconds, but it stays in the queue. Ack within that window and it is done and removed. Fail to — because you crashed, hung, or were killed — and the lease simply expires and the job becomes available to somebody else.

Nothing has to detect the crash. There is no heartbeat and no health check: a lease is a deadline written on the disk, and deadlines pass on their own.

jobs = q.lease(10, visibility_timeout=60.0)
for job in jobs:
    q.extend(job, 120.0)   # buy a slow handler more time
    q.ack(job)

Choosing a visibility timeout

Too short and a handler that is merely slow gets its job handed to someone else while it is still working — duplicate work from a perfectly healthy worker. Too long and a job held by a crashed worker sits invisible for that long before anyone retries it.

You do not have to choose. Keep it short (the 30-second default is fine) and use Worker, whose heartbeat extends the lease of any handler still running. Then short timeouts cost slow jobs nothing.

If you write your own loop instead, set it to a few times your slowest expected handler, or call q.extend(job, seconds) as you go.

What if your lease expires and you ack anyway?

You get NotLeasedError rather than silently completing a job someone else is now running. Every lease carries a token; when the job is handed on, the token changes, so a late ack is recognisable and refused. You find out that your work was orphaned instead of quietly corrupting state.

Retries, backoff and the DLQ

When a handler raises, the job is not lost and not immediately retried — it is put back with a delay that grows each time: 1 second, then 2, then 4, then 8. That is exponential backoff, and it exists because the usual cause of failure is something else being unhealthy, and retrying instantly makes that worse.

Jitter adds randomness to those delays. Without it, an outage that fails 10,000 jobs at once produces 10,000 retries at the same instant, then again at the same instant, forever — the queue becomes a synchronised hammer aimed at the thing that was already struggling.

After max_attempts tries the job stops retrying and moves to the dead-letter queue: not deleted, just set aside with its error message so a person can look. Attempts are counted when a job is leased, not when it fails — so a worker that dies silently still burns an attempt, and a crash-looping worker cannot retry one job forever.

from nobroker import BackoffPolicy

q = Queue("./jobs", backoff=BackoffPolicy(
    base=1.0,        # delay after the first failure
    factor=2.0,      # doubling
    max_delay=300,   # ceiling, so attempt 30 is not in 2089
    jitter=0.5,      # half fixed, half random
))

for job in q.dlq():
    print(job.id, job.attempts, job.last_error)
q.requeue_dead()      # revive the whole DLQ after fixing the bug
SettingDefaultRaise it when…
base1.0 sthe thing you call needs longer than a second to recover
factor2.0rarely — 2.0 is the standard doubling
max_delay300 sa long outage is normal and you would rather wait than dead-letter
jitter0.5you have many jobs failing together; 1.0 spreads them furthest
max_attempts5failures are usually transient. Lower it when they usually are not

The limitation to know about

nobroker has no "this will never work, skip the retries" call. A permanently bad input is retried exactly like a service that is briefly down — it just takes max_attempts tries to reach the DLQ instead of one. If that matters to you, catch the permanent case in your handler and record it yourself rather than raising.

Priorities and delays

Two ways to control when a job runs:

q.enqueue(payload, priority=9)   # jumps the line; higher wins
q.enqueue(payload, delay=300)   # not before five minutes from now

Ordering is (priority desc, available_at asc, insertion order) — so within one priority level it is first-in-first-out, and nothing overtakes unfairly.

Use priorities sparingly. Two or three levels is plenty. There is no ageing, which means a steady stream of priority-9 work will starve a priority-0 job indefinitely — it never becomes more urgent by waiting.

Why a delayed job does not block the queue

Eligible jobs and not-yet-eligible jobs are kept on separate heaps. Put them together and a high-priority job scheduled for tomorrow sits at the head of the queue, unrunnable, starving everything behind it — because you cannot look past the front of a heap. Splitting them is what makes priority and delay safe to combine.

Workers

Worker is the loop you would otherwise write yourself: lease a job, run the handler, ack it if it returns, nack it if it raises, repeat. Use it unless you have a reason not to — it gets three things right that hand-written loops usually get wrong (see below).

stats = Worker(
    q, handle,
    concurrency=4,        # threads — handlers here are usually I/O bound
    poll_interval=0.1,
    max_jobs=None,        # stop after N, for recycling workers
    idle_timeout=None,    # stop after N idle seconds
).run()

The three things it gets right:

1 · Ctrl-C does not lose work. SIGINT and SIGTERM flip a flag; in-flight handlers run to completion and ack normally, and only then does the process exit. Killing a worker costs you nothing, which is what makes deploying boring.

2 · Slow handlers are not redelivered. A background thread extends the lease of anything still running, so you can keep the visibility timeout short without punishing slow jobs.

3 · One bad job cannot take the worker down. A raising handler is data, not a crash; the job is nacked and the loop continues.

Threads or processes?

concurrency=4 means four threads. That is right for the handlers a queue usually runs — HTTP calls, sending mail, waiting on a database — because Python releases the GIL while waiting on I/O.

If your handlers are CPU-bound (resizing images, crunching numbers), threads buy you almost nothing. Run several worker processes instead — just start the command more than once. nobroker is safe across processes, which is the whole point.

run() returns WorkerStats. The field to watch is lost_leases: anything above zero means handlers are finishing after their lease expired, so work is being done twice and your visibility timeout is too short.

Several workers at once

Open the same directory from as many processes as you like, on the same machine. Nothing coordinates them and no notification is sent; each process takes a kernel file lock, reads forward from where it last got to, and finds out what everyone else did. Two workers can never be handed the same job, because the lock makes the read-decide-write sequence atomic.

# terminal 1, 2, 3 — all against the same directory
nobroker --dir ./jobs work myapp.tasks:handle

One machine only

This works because of a kernel file lock, which exists inside one operating system. It does not work over a network drive — not NFS, not SMB, not a shared cloud folder — and it cannot coordinate two machines. If you need work spread across servers, you need a real broker; nobroker will not grow one.

Named queues

Use a second argument to keep different kinds of work apart. Each name is an independent log with its own lock, so one directory holds emails, thumbnails and webhooks side by side without contending — and a flood of thumbnails cannot delay your password-reset mail.

emails = Queue("./jobs", "emails")
thumbs = Queue("./jobs", "thumbnails")

Compaction — keeping the file small

The log is only ever appended to, so a queue that has processed a million jobs holds a million records describing work that is finished and irrelevant. Nothing removes them on its own.

Compaction rewrites the log with only the jobs that still matter. Completed jobs are dropped; the dead-letter queue is kept, because it is a record of what failed rather than maintenance debris.

result = q.compact()
print(result.describe())
# compacted 2652568 -> 16 bytes (100.0% reclaimed), kept 0 jobs, dropped 5000 completed

You have to run this yourself

There is no automatic trigger — no size threshold, no background task. If you never compact, the file grows forever, and because opening a queue replays the whole log, startup gets slower and slower (roughly 48,000 records per second, so a million-record log takes about 20 seconds to open).

Run nobroker compact from cron, or call q.compact() somewhere in your own maintenance. It is safe at any time: it takes the same lock as everything else, and other processes notice by comparing a number.

Putting it together

What this looks like in a real application. A web request writes the job down and returns; a separate long-running process does the work.

# tasks.py -- shared by both sides
from nobroker import Queue

def queue():
    return Queue("/var/lib/myapp/jobs", "emails", max_attempts=5)

def send_welcome(job):
    user_id = job.payload["user_id"]
    if already_sent(user_id):        # the idempotency check
        return                       # returning = done, no duplicate
    mailer.send_welcome(user_id)
    mark_sent(user_id)
# in your web app -- fast, and durable before it answers
def signup(request):
    user = create_user(request)
    tasks.queue().enqueue(
        {"user_id": user.id},
        job_id=f"welcome-{user.id}",   # cannot be scheduled twice
    )
    return redirect("/welcome")
# the worker, as a service -- systemd, a container, or just a terminal
nobroker --dir /var/lib/myapp/jobs -q emails work tasks:send_welcome -c 4

Note the two halves of the duplicate problem being handled in the two places they belong: job_id stops the same work being scheduled twice, and the already_sent check stops it being performed twice.

Common mistakes

MistakeWhat happensDo this instead
Handler that is not safe to run twice A crash between doing the work and recording it sends the email twice Check before acting, or key on job.id
Visibility timeout shorter than the handler Healthy workers duplicate each other's work Use Worker, or call extend()
Never compacting The file grows forever and startup gets slow nobroker compact on a schedule
Putting the queue on a network drive The lock does not hold; two workers take one job Keep it on local disk
Non-JSON payloads (objects, bytes, datetime) SerializationError at enqueue Store an id and look the object up in the handler
Huge payloads The log balloons; over 8 MiB it is refused Store a file path or a row id, not the contents
fsync=False in production A power cut loses jobs you were told were saved Leave it on; use enqueue_many for speed
reference the whole surface

API reference

Nine public names, every argument, and what each one is for. New to this? Read the Quickstart first — this page assumes you know what a lease is.

Which method do I want?

I want to…Call
add one jobenqueue(payload)
add a lot of jobs quicklyenqueue_many(payloads)
add a job only once, everenqueue(payload, job_id="…")
run jobs, and not write the loopWorker(q, handler).run()
take one job myselflease_one()
say a job succeededack(job)
say a job failednack(job, error="…")
buy a slow handler more timeextend(job, seconds)
see what failed permanentlydlq()
retry everything that failedrequeue_dead()
count what is wherestats()
shrink the filecompact()
throw it all awaypurge()

Queue

Queue(path, name="default", *, fsync=True,
      visibility_timeout=30.0, max_attempts=5,
      backoff=BackoffPolicy(), lock_timeout=10.0)
ArgumentDefaultWhat it does
pathThe directory holding the queue's files. Created if missing. Must be on a local disk.
name"default"Which queue in that directory. Each name is fully independent, with its own log and lock.
fsyncTrueForce each write to the physical disk before returning. Turning it off is ~4× faster and gives up the guarantee.
visibility_timeout30.0Seconds a leased job stays hidden. When it passes, the job is available again.
max_attempts5Tries before a job goes to the dead-letter queue. Stamped on each job at enqueue.
backoffBackoffPolicy()How long to wait between retries.
lock_timeout10.0Seconds to wait for the file lock before raising LockTimeoutError.

fsync=False

It makes writes about four times faster and gives up the central guarantee: a machine crash can then lose work you were told was saved. It exists for benchmarks and for caches where loss is acceptable. If you want speed and durability, use enqueue_many — one fsync covers the whole batch, which is 40× faster per job with the guarantee intact.

Adding work

enqueue(payload, *, priority=0, delay=0.0,
        max_attempts=None, job_id=None) -> Job
enqueue_many(payloads, *, priority=0, delay=0.0, …) -> list[Job]
ArgumentMeaning
payloadAny JSON-serialisable value. Returned to your handler untouched.
priorityHigher runs first. Ties break first-in-first-out.
delaySeconds before it may run at all.
max_attemptsOverrides the queue default for this job only.
job_idYour own id. Enqueueing twice with the same one creates one job and returns the existing one.

Both fsync before returning. Raises SerializationError if the payload is not JSON-serialisable or exceeds 8 MiB.

Taking and finishing work

lease(count=1, *, visibility_timeout=None) -> list[Job]
lease_one(*, visibility_timeout=None)      -> Job | None
ack(job)                                    # -> None
nack(job, *, error=None, delay=None)     -> Job
extend(job, seconds)                        -> Job
job = q.lease_one()
if job is not None:
    try:
        do_the_work(job.payload)
        q.ack(job)                             # done, remove it
    except Exception as exc:
        q.nack(job, error=str(exc))            # retry, or DLQ if out of tries
leaseNever blocks. Returns fewer than count — possibly none — if that is all there is.
ackAcking an already-acked job is not an error. Raises NotLeasedError if your lease expired and the job moved on.
nackReturns the job in its new state — check .state is JobState.DEAD to see whether this was the failure that killed it. delay= overrides the computed backoff.
extendPushes the deadline out for a handler still working. Worker does this for you.

Looking and maintaining

get(job_id)                         -> Job
stats()                             -> QueueStats
list_jobs(state=None, *, limit=None) -> list[Job]
dlq(*, limit=None)                  -> list[Job]
requeue_dead(job_id=None)           -> int
purge()                             -> int
compact()                           -> CompactionResult
close()                             # or use `with Queue(...) as q:`
print(q.stats())
# QueueStats(ready=3, delayed=1, leased=2, done=41, dead=1, total=48)

for job in q.dlq():
    print(job.id, job.attempts, job.last_error)

q.requeue_dead()          # all of them back to ready, attempts reset
q.requeue_dead("order-42")  # or just one

stats() counts by walking every job, so it is O(n) — fine to poll every second, not fine to call in a tight loop over a million jobs. purge() deletes everything, including jobs a worker is holding right now.

Job

FieldTypeMeaning
idstrunique; yours if you supplied one
payloadAnywhatever you enqueued
stateJobStateready · leased · done · dead
priorityinthigher leases first
attemptsintcounted on lease, not completion
max_attemptsintthen the DLQ
available_atfloatnot leasable before this
lease_deadlinefloat | Nonewhen the lease expires
lease_tokenstr | Nonethe fencing token
last_errorstr | Nonefrom the most recent nack

DELAYED is deliberately not a state. A delayed job is READY with available_at in the future — one less state to keep consistent, and eligibility stays a pure function of the clock.

Worker

Worker(queue, handler, *, concurrency=1, poll_interval=0.1,
       max_jobs=None, idle_timeout=None, handle_signals=True).run()
ArgumentDefaultWhat it does
handlerAny callable taking one Job. Returning acks it; raising nacks it. The return value is ignored.
concurrency1How many threads run handlers. Right for I/O-bound work; for CPU-bound work run more processes instead.
poll_interval0.1Seconds to wait before asking again when the queue is empty. There is no server to push, so workers poll.
max_jobsNoneStop after this many jobs. Useful for recycling long-lived workers.
idle_timeoutNoneStop after this many seconds with nothing to do. None means run forever; 0 means stop as soon as the queue is empty.
handle_signalsTrueCatch SIGINT/SIGTERM for graceful shutdown. Set False when running a worker inside a thread.

run() blocks until stopped and returns WorkerStats(leased, succeeded, failed, dead, lost_leases). Call worker.stop() from another thread for a clean shutdown.

The number to watch

lost_leases counts handlers that finished after their lease expired. Anything above zero means work is being done twice: raise visibility_timeout, or find out why those handlers got so slow.

BackoffPolicy

BackoffPolicy(base=1.0, factor=2.0, max_delay=300.0, jitter=0.5)

How long a failed job waits before coming back. The delay for attempt n is base × factorn-1, capped at max_delay, then randomised by jitter.

jitter=0.0No randomness. Predictable, and useful in tests.
jitter=0.5Half fixed, half random — the default.
jitter=1.0Anywhere from zero to the full delay. Spreads a large failure furthest.
from nobroker import BackoffPolicy, Queue

q = Queue("./jobs", backoff=BackoffPolicy(base=5.0, max_delay=3600))
# waits ~5s, 10s, 20s, 40s ... up to an hour

Errors

All derive from NobrokerError, so except NobrokerError catches anything the queue can raise without swallowing bugs in your own code.

ErrorMeansUsually you should
NotLeasedErrorYour lease expired and the job went to someone elseStop; the other worker owns it now
JobNotFoundErrorNo job with that id — normally because it was purgedIgnore it
LockTimeoutErrorCould not get the file lock within lock_timeoutRetry, or raise the timeout
SerializationErrorThe payload is not JSON-serialisable, or is over 8 MiBStore an id instead of the object
CorruptLogErrorDamage recovery cannot resolve: a bad header, or a format version this build does not knowInvestigate — this is not routine
CompactionErrorCompaction failed; the previous log is untouched and still usableCarry on; try again later
QueueClosedErrorYou used a queue after close()Fix the lifetime

A torn tail from a crash is not an error — it is repaired on open and reported through q.recovery, which has a .describe() worth logging at startup.

internals how it is built and what it cost

Design notes

The decisions, the measurements, and the three bugs worth writing down.

On disk

jobs/
  emails.000001.log     # the write-ahead log — the only durable state
  emails.current        # one line: which generation is authoritative
  emails.lock           # the cross-process lock

Everything in memory — the priority heaps, the lease table, the DLQ — is a cache of the log. There is no second source of truth to keep consistent with it, which is what makes crash recovery tractable rather than hopeful.

Replay is a pure function of the log

Nothing in the replay path reads the clock, mints a UUID, or samples jitter. Every non-deterministic value — the lease deadline, the jittered retry time, the job id — is decided once by the writer and recorded as an absolute value. Replaying a log twice therefore produces identical state, which is the property that makes the exhaustive truncation test possible at all.

Compaction without overwriting

BEFORE THE FLIP emails.000001.log 2.6 MB, authoritative emails.000002.log written + fsynced, inert emails.current → 1 os.replace one atomic write AFTER THE FLIP emails.000001.log orphan, swept later emails.000002.log 16 KB, authoritative emails.current → 2 The pointer file is the commit point. Nobody ever holds it open, so replacing it is always permitted — on every platform. Crash before it: the old log is still live and the new file is ignorable garbage. Crash after it: the new log is live and complete. There is no third state.
Peers notice a compaction by comparing an integer, not by stat'ing inodes with a platform-specific fallback. This design was forced by a Windows bug and turned out better than what it replaced.

Benchmarks

Windows 11, NVMe SSD, Python 3.11.9, via make bench. Run it on your own hardware — NVMe versus spinning rust changes the fsync rows by orders of magnitude.

Operationops/secµs/opNotes
enqueue (fsync per job)1,348741.7the guarantee, paid one job at a time
enqueue_many (one fsync)57,67617.3batching amortises the fsync
enqueue (fsync=False)6,014166.3not durable — shown for contrast
lease+ack round trip1,433697.9batches of 100, acked individually
cold-start replay47,73221.0full replay, CRC of every record
compact537,1841.92.6 MB → 16 bytes
nack + reschedule6,712149.0backoff, availability, re-heap

Three bugs worth writing down

Peers overwrote each other's records

First run of the multi-process test: four producers × 40 jobs produced 35 jobs, not 160. Four consumers leased 615 jobs from a queue of 200. The append offset was a per-process cache that the read path never refreshed, so a peer appended on top of another's records. Invisible to every single-process test.

os.open defaults to text mode on Windows

Compaction failed roughly one run in six, losing a varying number of jobs with a checksum error in a file it had just written and fsynced. A descriptor without O_BINARY silently rewrites every 0x0A as 0x0D 0x0A. JSON escapes newlines in payloads, so it only fired when a record's binary length or CRC field happened to contain one — hence intermittent, and hence surfacing at reopen rather than at write.

Compaction left stale state in memory

The heaps were rebuilt but the job table was not, so memory held jobs the log no longer mentioned. The disagreement would have vanished on the next restart — correct after a reboot, wrong until then, which is the worst failure shape there is.

Limits

  • Single machine only. Kernel file locks do not work over NFS or SMB and will not coordinate two hosts.
  • At-least-once, never exactly-once. Handlers must be idempotent.
  • Polling, not push. A broker-less queue has nobody to send a notification. Cheap, but not free and not zero-latency.
  • The whole index lives in memory. ~500 bytes per job, so a million jobs is ~500 MB.
  • stats() is O(n). It counts by iterating.
  • Startup replays the whole log at ~48k records/sec. Compact regularly.
  • No result backend, chaining, workflows, cron DSL, or async handlers. Deliberately out of scope.
  • Payloads must be JSON-serialisable. The price of a readable log.
  • Windows: directory fsync does not exist, so rename durability is weaker than on POSIX.
  • Clock-dependent. A large backwards clock step can delay lease reclaims by that amount.
objections the questions a reviewer asks

Objections

Answered before you have to ask them.

"Why not just use sqlite3? It's stdlib too."

SQLite would give storage and transactions. It would not give any of the things this project actually is: lease semantics, visibility timeouts, backoff with jitter, fencing tokens, a dead-letter queue, or priority ordering with delayed jobs.

A job queue on SQLite is all of this same code written on top of a query layer you do not need, plus a schema, plus the BEGIN IMMEDIATE/busy_timeout dance to stop polling workers livelocking. More code, not less.

The honest counterpoint

SQLite's storage engine is vastly better tested than this one. If you need a queue you would bet a company on today, that is a real argument for it.

"Why not exactly-once?"

Because nobody delivers it. A worker can complete its side effect and then die before acknowledging; the job comes back and the side effect happens twice. Closing that window requires the handler's side effect and the queue's acknowledgement to commit in a single transaction — which means the queue must live inside your database, and then it is not a general-purpose queue any more.

"Is it faster than Redis?"

No. Redis will do 50,000–100,000 ops/sec on loopback. nobroker does ~1,300 durable enqueues/sec, because it calls fsync and Redis by default does not. That is not a fair fight in either direction — nobroker is paying for a guarantee Redis is not making.

  • vs Redis with appendfsync always — the same promise — you are in the same order of magnitude, minus a network hop and a server.
  • vs Redis default (everysec, can lose a second of acknowledged writes) — the comparable setting is fsync=False, labelled "not durable" everywhere it appears.
  • On batches, enqueue_many does 57k/sec durably.

If you need 100k jobs/sec, run a broker. If you need 1,000 jobs/sec that are still there after the power cut, this is simpler and there is nothing to operate.

"Isn't a file lock per operation slow?"

It was — 44% of a non-durable enqueue, spent opening and closing the lock file. The descriptor is now held for the queue's lifetime and only the lock is taken and released, which was a 2.4× improvement with no semantic change. Both flock and msvcrt.locking associate the lock with the open file description, so it is exactly as exclusive as reopening each time.

"What if two processes compact at once?"

They cannot. Compaction runs inside the same lock as every other operation.

"What if the machine dies during compaction?"

The pointer file either got flipped or it did not. If it did, the new generation is live and complete. If it did not, the old one is still live and untouched, and the half-written new generation is inert garbage that the next compaction overwrites.