Skip to main content

due-work-harness

CI PyPI Python License: Apache-2.0 pytest plugin Ruff types: Pyrefly uv

Crash-test your background jobs. Pytest proofs that background work survives lost messages, dead workers and uncertain external calls, and that the test saying so isn't lying.

What it is, in 45 seconds (with sound):

https://github.com/user-attachments/assets/498ac071-080c-45d2-abdb-13aee405568b

Your happy-path test is an optimist

Most applications record something now and finish it later: send the confirmation email, generate the thumbnails, publish the post, retry the failed upload. That is due work, and it fails quietly:

  • The commit landed. The message didn't. The order exists; its email never will.
  • The email went out. Then the worker died. Nothing recorded the send, so recovery sends it again.
  • The stale worker finished last. It overwrote the fresh result its replacement had just written.

Nothing throws, nothing pages anyone, and the test that runs the happy path once stays green. due-work-harness kills your worker on purpose, after every commit and after every external call, and fails unless every one of those histories ends where normal operation does.

Not theoretical: it found these in code you've heard of

due-work-harness finding a lost handoff in procrastinate's demo, its fix passing, a repeated notification in DBOS's outbox demo, and the coverage scan of Saleor's checkout

Real output, recorded with vhs from docs/demo.tape. CI replays every command it types (demos/test_readme_gif.py), so the GIF can't claim output the harness no longer prints.

The demos run the harness against demo applications and a real e-commerce platform, exactly as their projects ship them, pinned to a commit:

Where What one badly timed failure does What fixes it
django-tasks-db (Django's task framework, database backend) A task_finished receiver that raises rewrites a task that already ran as FAILED; a task whose worker died stays RUNNING forever —
Wagtail on django-tasks-db Deleting an image or document can leave its file in storage for good; publishing can leave the CDN serving the old page —
Saleor checkout, Payments API A worker death between capturing the payment and creating the order charges the customer and never creates the order. After 90 days the payment belongs to nothing Saleor's Transactions API with automatic completion of paid checkouts: the same deaths always end with an order
Saleor checkout A death after the order commits leaves it unconfirmed, its history empty or half-written —
Saleor checkout No death at all: a failing order_created callback, or the broker refusing any one of the order's webhooks, and Django skips the confirmation —
Saleor automatic completion It dispatches a paid checkout again while that checkout's completion is still in flight, and reports no backlog —
DBOS transactional-outbox A death after sending the notification, before DBOS records the step: the customer is notified twice An idempotent notification (a key the recipient deduplicates)
procrastinate demo_django A death between committing the book and deferring its job: the book is never indexed, and nothing finds it again ATOMIC_REQUESTS = True: the same proof passes
procrastinate demo_django A job whose worker died is never picked up again procrastinate's documented retry_stalled_jobs task: the same proof passes
procrastinate itself finish_job doesn't check the worker: a worker presumed dead finishes a job another worker has since fetched — (a fencing token on finish)
RQ itself A worker whose lease expired still settles the job after another worker took it: it marks it finished, or sends it back to be retried, while the new worker runs it —
RQ itself The reply to the write that records a job finished is lost, or its on_success callback raises: RQ fails the finished job and runs it again —
RQ itself A worker listening on two queues dies, or loses a reply, between popping a job and marking it started: the job is gone, queued in no queue and no registry —
Celery itself A worker's pool child dies after the task's SUCCESS is stored: the task stays SUCCESS and its error callback fires beside its link —
Celery itself A task's on_success hook raises after SUCCESS is stored: the same, both callbacks fire —
Celery itself The broker refuses the task's link after the body ran: the task is recorded FAILURE and acknowledged, so the link is never sent —

Demos are teaching code, and good at what they teach. The point is what the harness finds when code like this is copied into production, and the one change that makes the same proof pass. Every finding is a strict xfail with its explanation, so an upstream fix turns it red. The full write-up: demos/README.md.

The findings, in 30 seconds: Saleor's checkout run, its fix passing, then DBOS, procrastinate and the coverage scan, all real output:

https://github.com/user-attachments/assets/ed065709-3372-40dd-8dc9-7f64dc60ef90

Install

pip install due-work-harness            # the core: pytest and pydantic, nothing else
pip install "due-work-harness[django]"  # plus the Django/PostgreSQL integration

Or uv add --dev due-work-harness. The extras are [django], [celery], [procrastinate], [dbos], [redis] and [rq]; combine as needed. Python 3.12+.

Kill it on purpose: crash histories

Give the harness one transition that hands work off. It runs normal operation to learn the outcome, then replays the same transition:

  • with every message it published lost;
  • with the worker killed right after each commit it makes;
  • with the worker killed right after each external call returns;
  • with each after-commit callback failing, each signal receiver or job callback failing, the broker refusing each publication, and the reply to each commit lost (the write lands, the worker sees a connection error and carries on), where your host supports them (the Django host breaks callbacks; the Redis host loses replies; add the Celery integration's breaker to refuse publishes).

Every history must reach the outcome normal operation reached. The harness finds the commit boundaries itself; you don't name them.

from due_work_harness import CallableDelivery, ExternalCall, HandoffHistory, assert_crash_at_every_commit_converges


def test_placing_an_order_survives_any_death():
    assert_crash_at_every_commit_converges(
        CallableDelivery(name="orders", recover=run_workers_until_idle),  # what production runs after a crash
        HandoffHistory(
            name="place order",
            arrange=new_cart,
            transition=place_order,  # your real view or service method
            observe=lambda cart: (order_status(cart), mailbox.count(cart)),  # include what external systems saw
            external_calls=(ExternalCall(mailer, "send"),),
        ),
    )

A failure names the history that went wrong and what it left behind:

AssertionError: orders: handoff 'place order': normal operation reaches ('SENT', 1), but these
histories reach something else: {'worker died after external call 1': ('SENT', 2)}. Work was lost
or repeated. ...

That's the duplicate email, caught in a test instead of in a support ticket. When a test can't reach the worker at all (a workflow engine's own executor), process_histories runs the real program in a child process, kills it at named points with os._exit, restarts it the way production would, and applies the same verdict. That is how the DBOS demo above works.

Six ways due work goes missing: lifecycle profiles

A crash history proves one handoff. A declarative DueWorkContract binds your production selection, tick and transitions, and asks for a disposition (claim, decline with a reason, not applicable, or known gap) for each of six profiles:

Profile The question it answers
A automatic recovery After a lost message, is the work still found? Does anything actually run the sweep, index-served and bounded?
B bounded ownership Who owns the work right now, and what happens when that owner's lease expires?
C crash ambiguity The provider never answered. Did the email go out? Schrödinger's email: can you tell "never ran" from "maybe ran"?
D durable retention Can a cleanup pass delete work that is still owed?
E eventual convergence When a result lands late, can it overwrite a newer one?
F fact-derived obligations Can product state imply work nothing recorded, and is it still found?

Every contract also disposes of two execution-safety profiles: replay safety and bounded retry. Read what a green result means before you treat a pass as a guarantee.

Green you can trust

A conformance suite can lie in ways ordinary tests can't. An adapter that copies production's query, or implements a tiny state machine in the test file, makes every proof measure the copy, and it stays green forever. So the harness:

  • refuses test-authored selections and transitions, and requires every binding to reach code in your production_packages;
  • forbids waiving its own integrity checks;
  • pairs every "must not happen" proof with a positive control showing the same binding would act;
  • fails a run where every crash history agrees but recovery changed nothing: agreeing with an inert recovery is not a pass.

The full catalogue of lies it refuses: false greens.

No handoff left behind: the coverage scan

A crash history proves one handoff; the coverage check makes sure none are forgotten. It scans production code for every call that hands work off: on_commit, a Celery .delay, a procrastinate .defer, a DBOS workflow start, a Dramatiq .send, an RQ enqueue, a Django task's .enqueue. It attributes each to the exact function that makes it, and requires exactly one disposition per function: a contract suite that insures it, or an exemption that proves losing it costs nothing. In Saleor it finds 137 handoff sites in 124 functions.

uv run due-work-harness check      # static: imports neither your app nor your tests
@due_work_contract_suite(ORDER_NOTIFICATIONS, covers=(DueWorkSource(OrderService.place),))
class TestOrderNotificationsDueWork:
    pass

Aliases, re-exports and handoffs passed along uncalled are still found. A declaration counts only if pytest would actually run it, and pytest --due-work-verify then checks that it did. An existing project adopts with a baseline that only shrinks.

In GitHub Actions, gigaverse-app/due-work-harness/check@v0 runs the scan and gigaverse-app/due-work-harness/test@v0 runs the generated suites (every generated case carries the due_work mark). The path from install to CI is in ADOPTING.md.

Bring your own framework

The core depends only on pytest and pydantic: no Django, SQLAlchemy, Celery, procrastinate or DBOS, and CI imports every core module with none of them installed. Proofs take plain callables. The few facts only a framework knows (whether a transaction is open, how to interrupt a commit, what plan a query runs) come from a Host you configure once:

# conftest.py
from due_work_harness import configure
from due_work_harness.integrations.django import django_host

configure(django_host(production_packages={"myapp"}))
Extra Supplies
[django] django_host(): pytest-django marks, the transaction probe, a commit counter that kills the worker after any commit (including writes made through SELECT fn()), PostgreSQL plan inspection, lifecycle-state proofs
[celery] beat-schedule evidence, a publication recorder that holds messages instead of sending them, a publication breaker that refuses one publish as a broker that is down would; and celery_worker: the application's real worker as a child process, failing at Celery's own stages (the pool child lost at task_prerun, mark_as_done and task_postrun, a raising on_success hook, a refused link), with worker_contract() for any adopter's task
[procrastinate] its worker as recovery, "worker died holding this job" arrangement, the documented stalled-job recipe, periodic-task evidence
[dbos] restarting an app through its own startup for process-level crash histories
[redis] redis_host(): a commit counter for a queue kept in Redis (each pipeline or write command a commit, judged by the server's own command flags), and a reply breaker that lets a write land and loses its answer
[rq] RQ's worker as the transition and as recovery (later workers' maintenance, with the clock moved on), its ownership bound to profile B, a breaker for job callbacks, and worker_contract(): RQ's whole contract for any adopter's jobs

Status

Alpha. The proofs were extracted from a production codebase, where they guard its background workflows in CI; the public API may still change before 1.0. Python 3.12+. How it's built: ARCHITECTURE.md.

Development

Managed with uv (uv sync --all-extras), linted with Ruff and type-checked with Pyrefly. See CONTRIBUTING.md.

License

Apache-2.0. See LICENSE.

Metadata

Release files for due-work-harness 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for due-work-harness 0.3.0
File Size Uploaded
due_work_harness-0.3.0.tar.gz 462.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for due-work-harness 0.3.0
File Interpreter ABI Platform
due_work_harness-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 684.8 kB

Release files / due_work_harness-0.3.0.tar.gz

Download URL due_work_harness-0.3.0.tar.gz
Size 462.4 kB
Tags Source
SHA-256 checksum
How to use checksums
1515ded6d32201d72a1f16a577f62175b368e68e6e7c9e3611bcdd07abd2abef
BLAKE2b-256 checksum
How to use checksums
097344583a9556f6c09a76191316145ebba7b12dcbf096c5b6306297c84efd11
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release files / due_work_harness-0.3.0-py3-none-any.whl

Download URL due_work_harness-0.3.0-py3-none-any.whl
Size 222.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
56f21d33a41141cede2bc5f2f4bf9731598aef927cf3a728f39fea41c4091c39
BLAKE2b-256 checksum
How to use checksums
b9fe60ac9d4148647c4cf889565cce7e5d44e03b063bff3431df1abec61eaaa0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release history Release notifications | RSS feed

0.7.0

2 release files

0.6.0

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page