grampy
A small workflow graph for work queues that already live in a storage — a database, a key-value store, or plain memory: the storage is a driver.
pip install grampy-q
pip install "grampy-q[postgres]" # + the PostgreSQL drivers (SQLAlchemy)
The distribution is grampy-q — the q is for queue, grampy being taken on
PyPI — and it is imported as quazardous.grampy.
Try the brick sorter → — a sorting line where bricks are sorted by colour, TNT bricks get quarantined, defused with retries or thrown away, and a sorted brick sent back waits in a lane before it runs again. It runs the real library in your browser.
You declare a DAG of nodes. Each subject (a job, a request, an offer…) goes
through the nodes; a node journal records, per subject and per node,
whether the node is running, done, skipped, failed or omitted.
Workers claim a node for eligible subjects, conclude it, and the graph
decides what becomes claimable next — forks run in parallel, joins wait for
their parents.
from dataclasses import dataclass
from quazardous.grampy import Node, NodeJournal
from quazardous.grampy.drivers.memory import MemoryDriver
from quazardous.grampy.items import Adapter, Items
@dataclass
class Order: # your object, as it is
id: int
total: float
class Orders(Adapter):
def id_of(self, order): return order.id # the one thing grampy must know
GRAPH = (Node("pay"), Node("ship", parents=("pay",)))
items = Items(NodeJournal(MemoryDriver(), GRAPH), Orders())
orders = [Order(1, 30.0), Order(2, 12.5)]
lease = items.claim("pay", 10, candidates=orders) # orders in, orders out
for order in lease:
print("paying", order.id, order.total)
items.conclude("pay", lease)
print(items.progress(orders[0])) # {'pay': Status.DONE}
print(list(items.claim("ship", 10, candidates=orders))) # pay is done: ship is next
That runs as pasted. A larger graph, with subjects that differ:
from dataclasses import dataclass
from quazardous.grampy import Node, NodeJournal, check_dag
from quazardous.grampy.drivers.memory import MemoryDriver
from quazardous.grampy.items import Adapter, Items
DAG = (
# `working` and `state`: what YOUR status column says while the node runs
# and once it is done — the journal records `running` and `done`.
Node("fetch", working="fetching", state="fetched"),
Node("crop", parents=("fetch",), optional=True),
Node("read", parents=("fetch",), optional=True),
Node("judge", parents=("crop", "read"), state="judged"),
)
check_dag(DAG)
@dataclass(frozen=True)
class Doc:
id: str
scanned: bool = True
class DocsAdapter(Adapter): # how grampy reads YOUR object
def id_of(self, doc): return doc.id
def applies(self, doc, node): # one graph, subjects that differ
return node != "crop" or doc.scanned
def my_loader(): # your query, your ORM
return [Doc("s1"), Doc("s2", scanned=False)]
items = Items(NodeJournal(MemoryDriver(), DAG), DocsAdapter())
lease = items.claim("fetch", 10, candidates=my_loader()) # objects in…
for doc in lease: # …and objects out
...
items.conclude("fetch", lease)
working and state are projections, not commands: allowed_transitions()
turns them into the transitions your own status column may take — see
the rules.
Handing grampy your objects is the canonical way to use it — see
items. The
core underneath works on ids alone and stays available: journal.claim("fetch", 10, candidates=[…]) returns subjects, and never reads your data.
Why not a status column?
A status column and a few UPDATEs hold one line of steps, one worker at a
time. What grampy adds, each proven by the shared driver contract
(quazardous.grampy.testing.JournalContract):
- Two workers, one subject. A claim is atomic and returns a token; a slow
worker whose lease went to another writes nothing
(
test_concurrent_claimers_never_take_a_subject_twice,test_a_released_lease_cannot_conclude_the_next_one). - A graph, not a line. Forks run in parallel, joins wait for their
parents, optional steps and exclusive choices do not block what follows
(
test_the_driver_claims_exactly_what_the_rule_says,test_a_child_waits_for_its_parent_to_conclude). - Going back. Forget, replay and loops keep what they take away, with when
and why (
test_forget_and_release_archive_what_they_take_away). - Time. Retries, leases, waits and grace periods, settled by one janitor
call (
test_a_failure_is_retried_when_due_then_stands).
What it does
Each line links to the rules, where it is spelled out.
| joins as data | which parent statuses a node accepts, k of n, a step that runs on a failure |
| exclusive choices | a node names its branch; what only the others lead to is omitted at once |
| loops and history | bounded ways back, every row taken away kept with when and why |
| retries | declared backoff — constant, linear or exponential, capped, with jitter |
| leases | per node, given back by journal.expire() when a worker dies |
| waits and grace | settled by a durable signal, recorded even before the wait, or failed at its timeout |
| groups | subjects worked together — five of a colour, ten thousand for one file — a whole group or none |
| lanes | a subject that comes back waits, merges with the version waiting, runs again after a cooldown — a document edited five times in a minute runs once, on its last version |
| policies | one workflow, subjects treated differently: their own retries, leases and budgets |
| rate and concurrency | several bands at once (GCRA, with bursts), a cap per node, per policy |
| versions | subjects pinned to the graph they started on, migrated all or nothing |
| items | speak your objects: handlers name a branch, or give up a step one kind skips |
| drawings | Mermaid flowchart, Mermaid state diagram, Graphviz, with live counts |
Storage
The journal never commits and never reads your tables. Eligibility is your
query, passed to claim and read inside its transaction; a claim gives back
ids, and you load your own objects. See drivers.
| driver | needs |
|---|---|
drivers.memory |
nothing — the reference the others are confronted with |
drivers.sqlite |
the standard library |
drivers.postgres |
SQLAlchemy Core — three table layouts, your choice |
Any other storage: implement the driver protocol and pass the shared contract
(quazardous.grampy.testing.JournalContract), concurrency tests included.
Next to other tools
grampy is a journal, not a runner: the comparison is about where the state lives. Each cell links the page that says so; — is a question that page does not settle. Checked on 2026-09-18.
| your own database | enqueue in your transaction | a server of its own | joins between steps | Python | |
|---|---|---|---|---|---|
| grampy | yes: memory, SQLite, PostgreSQL | yes | no | yes: all parents, k of n, on a failure |
yes |
| Graphile Worker | yes, PostgreSQL | — (add_job is SQL; rollback not stated) |
no | — | no, Node.js |
| BullMQ | Redis; PostgreSQL optional | — | no, but Redis or PostgreSQL | flows: all children | yes, bullmq |
| Hatchet | no, the engine's | — | yes | all parents | yes |
| Inngest | no, a managed state store | — | yes, or Inngest Cloud | in code, Promise.all |
yes |
| Temporal | no, the service's | — | yes, or Temporal Cloud | in code | yes |
| Oban | yes | yes, Ecto.Multi |
no | Pro workflows | yes, oban-py, PostgreSQL only |
How each notion maps, tool by tool: the same notions in other tools.
Documentation
- The rules — every mechanism, spelled out.
- Items — objects instead of ids, the canonical way.
- Drivers and candidates — tables, queries, your own data.
- Writing a driver — the capabilities, what each method may return, certifying with the contract.
- Operating in production — the indexes worth adding, measured; what grows; the janitor.
- grampy's tables next to yours — what to read, what never to write.
- Drawings — diagrams from a graph.
- The same notions in other tools — Graphile Worker, BullMQ, Hatchet, Inngest, Temporal, Oban.
- CONTRIBUTING — tests, lint, how to release.
Tests
pip install -e ".[test]" # or: uvx --with pytest pytest
pytest # graph, states, memory and SQLite drivers
GRAMPY_TEST_PG_DSN=postgresql+psycopg://user:pass@localhost/test \
pytest # + the postgres driver (needs sqlalchemy and a driver)
License
MIT
Release files for grampy-q 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| grampy_q-0.5.0.tar.gz | 148.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| grampy_q-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 281.6 kB
Release files / grampy_q-0.5.0.tar.gz
| Download URL | grampy_q-0.5.0.tar.gz |
|---|---|
| Size | 148.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f25c69a0cd15428987987795dfd79e2950cf197c48a4d86b7a6f243bdfcc8a10
|
|
BLAKE2b-256 checksum How to use checksums |
e0aeb0cb95e144122c50b1f83ef9b80d08b5965614ee951cc295adc44208b5cd
|
| 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 18, 2026.
Transparency logRelease files / grampy_q-0.5.0-py3-none-any.whl
| Download URL | grampy_q-0.5.0-py3-none-any.whl |
|---|---|
| Size | 132.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9843bd2a8a183f270d36d097b1b693fb7e3cebc82d2764faa9069b27e6651ea0
|
|
BLAKE2b-256 checksum How to use checksums |
4a7a80b2a237adddf8bf66f019e86624d18b4546d8ae48bac518f2e572e4d999
|
| 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 18, 2026.
Transparency log