deferjob 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ environment:
15
+ name: pypi
16
+ url: https://pypi.org/p/deferjob
17
+ permissions:
18
+ id-token: write
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+ - name: Build
25
+ run: |
26
+ python -m pip install --upgrade build
27
+ python -m build
28
+ - name: Upload to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,39 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ services:
12
+ postgres:
13
+ image: postgres:16
14
+ env:
15
+ POSTGRES_USER: deferjob
16
+ POSTGRES_PASSWORD: deferjob
17
+ POSTGRES_DB: deferjob
18
+ ports:
19
+ - 5432:5432
20
+ options: >-
21
+ --health-cmd pg_isready
22
+ --health-interval 10s
23
+ --health-timeout 5s
24
+ --health-retries 5
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+ - name: Install
31
+ run: pip install -e ".[dev]"
32
+ - name: Tests
33
+ env:
34
+ DATABASE_URL: postgresql://deferjob:deferjob@localhost:5432/deferjob
35
+ run: pytest
36
+ - name: Lint
37
+ run: ruff check src tests
38
+ - name: Types
39
+ run: mypy
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .coverage
14
+ htmlcov/
15
+ *.swp
16
+ .idea/
17
+ .vscode/
deferjob-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waseem Jan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,436 @@
1
+ Metadata-Version: 2.5
2
+ Name: deferjob
3
+ Version: 0.1.0
4
+ Summary: Durable delayed jobs in Postgres. Schedule work months ahead.
5
+ Project-URL: Homepage, https://github.com/syedwaseemjan/deferjob
6
+ Project-URL: Repository, https://github.com/syedwaseemjan/deferjob
7
+ Project-URL: Issues, https://github.com/syedwaseemjan/deferjob/issues
8
+ Project-URL: Blog, https://waseem.is-a.dev/blog/scheduling-months-ahead-without-celery/
9
+ Author: Waseem Jan
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: deferred,delayed,jobs,postgres,scheduler
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Database
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: psycopg[binary]>=3.1
23
+ Provides-Extra: dev
24
+ Requires-Dist: mypy>=1.10; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # deferjob
30
+
31
+ A small Python library for work that must happen later, hours later or months later, and still be there when the day arrives.
32
+
33
+ The schedule lives in **Postgres**, as rows in a table. You insert a job when you book the event. You change the date when the event moves. You cancel the job when a dispute opens. A worker process asks Postgres, once a minute, “what is due?” and runs those rows.
34
+
35
+ It is a pip package (`pip install deferjob`), not a Postgres extension. You need Postgres 13+ and Python 3.11+.
36
+
37
+ This exists because [Celery `eta` is the wrong place to keep work that is months away](https://waseem.is-a.dev/blog/scheduling-months-ahead-without-celery/).
38
+
39
+ ---
40
+
41
+ ## What it is
42
+
43
+ deferjob is a **durable delayed-job table** plus a **poller**.
44
+
45
+ - **Durable** means the future work is data in your database. A deploy, a crash, or a new server does not forget that a wedding closes in November.
46
+ - **Delayed** means “run at this timestamp,” not “run as soon as a worker is free.”
47
+ - **Job** means a named piece of your code (close the event, complete the order, auto-resolve the dispute) plus a small JSON payload, usually just ids.
48
+
49
+ It is not a general job queue, not a workflow engine, and not a cron replacement. Those tools solve different problems. See [What it cannot do](#what-it-cannot-do).
50
+
51
+ ---
52
+
53
+ ## Why it exists
54
+
55
+ Imagine a customer books a chef in August for a wedding in November. On the event date you close the event. A few hours later you mark the order complete and pay the chef, unless someone opened a dispute.
56
+
57
+ That is not “run this in a few seconds.” That is “remember this for three months, then do it, and let me change my mind in between.”
58
+
59
+ A common shortcut is Celery’s `eta`: tell the worker a timestamp, walk away. That looks like one line. The promise is actually being kept in a worker process’s memory, or in Redis treated as a cache. Then:
60
+
61
+ - A deploy restarts the workers. The November job was sitting inside August’s process.
62
+ - Redis visibility timeout cannot tell “this worker is waiting until November” from “this worker died.” The same job gets handed out again and again, or a real crash sits unhealed for as long as your longest delay.
63
+ - Moving the wedding means revoking a broker task id you stored on the booking. You cannot `SELECT` “everything due next week.” The schedule is a side effect you chase with ids.
64
+
65
+ The thing you actually care about (*this event closes on this date*) is a fact about the booking. It should be a row next to the booking.
66
+
67
+ That is all deferjob is. Postgres remembers. A worker checks the clock.
68
+
69
+ ---
70
+
71
+ ## What it does, and why
72
+
73
+ ### 1. Store the job as a row
74
+
75
+ Scheduling is an `INSERT` into `defer_jobs`.
76
+
77
+ ```python
78
+ from datetime import datetime
79
+ from zoneinfo import ZoneInfo
80
+ from deferjob import Defer, Job
81
+
82
+ jobs = Defer("postgresql://localhost/app")
83
+ jobs.install()
84
+
85
+ jobs.schedule(
86
+ "close_event",
87
+ run_at=datetime(2024, 11, 12, 9, 0, tzinfo=ZoneInfo("UTC")),
88
+ payload={"event_id": 42},
89
+ key="event:42:close",
90
+ )
91
+ ```
92
+
93
+ **Why:** A row survives deploys, new machines, and Redis failovers. You can open psql and see it. You can back it up with the rest of the database. The delay is the `run_at` column, not a process that has to stay alive until November.
94
+
95
+ `run_at` must be timezone-aware. Naive datetimes are rejected. Scheduling is a clock problem; guessing the timezone is how you close a wedding on the wrong day.
96
+
97
+ `payload` should be identifiers (`event_id`, `order_id`), not a copy of the order as it looked in August. In November the handler loads the live row.
98
+
99
+ ### 2. Give the job a name you chose (`key`)
100
+
101
+ `key` is optional, but it is the way you should talk about a job.
102
+
103
+ ```text
104
+ event:42:close
105
+ order:99:complete
106
+ dispute:7:autoresolve
107
+ ```
108
+
109
+ **Why:** In the Celery version, every table grew a `*_task_id` column that pointed at a message inside the broker. To move a date you had to find that id and hope `revoke` reached the worker holding it.
110
+
111
+ Here the key *is* the name. One pending job per key (enforced by a unique index). You do not store a broker id on the booking.
112
+
113
+ ### 3. Move a date, or cancel, like any other data
114
+
115
+ ```python
116
+ jobs.reschedule(key="event:42:close", run_at=new_end)
117
+ jobs.cancel(key="order:99:complete")
118
+ jobs.list(key_prefix="event:42:")
119
+ ```
120
+
121
+ **Why:** Customers move weddings. Disputes open. “What is still scheduled for this booking?” is a product question. Those are updates and selects, not control-plane RPCs to a worker fleet.
122
+
123
+ `cancel` and `reschedule` only work while the job is **pending**. If a worker has already claimed it (`running`), these calls raise `JobNotPending`. They do not stop a handler that is already in progress. See [What it cannot do](#what-it-cannot-do).
124
+
125
+ ### 4. Put the job in the same transaction as the booking
126
+
127
+ ```python
128
+ with psycopg.connect(DSN) as conn:
129
+ booking_id = insert_booking(conn, ...)
130
+ jobs.schedule(
131
+ "close_event",
132
+ run_at=event_ends_at,
133
+ payload={"event_id": booking_id},
134
+ key=f"event:{booking_id}:close",
135
+ conn=conn,
136
+ )
137
+ conn.commit()
138
+ ```
139
+
140
+ **Why:** You do not want a booking with no close job, or a close job with no booking. If the request fails after the insert, both roll back.
141
+
142
+ If you pass `conn=`, deferjob **does not commit it**. That connection is yours. If you omit `conn=`, deferjob opens its own connection and commits.
143
+
144
+ ### 5. Run what is due, without holding the future in memory
145
+
146
+ A worker process loops:
147
+
148
+ 1. Give back jobs stuck in `running` for too long (a worker crashed mid-job).
149
+ 2. Ask Postgres for the next pending row whose `run_at` is in the past.
150
+ 3. Mark it `running` and run your handler.
151
+ 4. Mark it `done`, or put it back as `pending` for a retry, or mark it `failed`.
152
+
153
+ ```python
154
+ @jobs.job("close_event")
155
+ def close_event(job: Job) -> None:
156
+ event = events.get(job.payload["event_id"])
157
+ if event.already_closed:
158
+ return
159
+ event.close()
160
+
161
+
162
+ jobs.run() # process; polls about every 60 seconds
163
+ ```
164
+
165
+ Or:
166
+
167
+ ```bash
168
+ deferjob worker --app myapp.jobs:jobs
169
+ ```
170
+
171
+ **Why:** The worker can die at any time. The November jobs are not inside it. They are still pending rows. The next worker will see them when November comes.
172
+
173
+ Several workers can run at once. Claiming uses `FOR UPDATE SKIP LOCKED`: if one worker has a row, the next worker skips it and takes a different one. They do not block each other.
174
+
175
+ A job may run up to one poll interval late (60 seconds by default). For closing an event or paying a chef the next day, a minute does not matter. Still having the job in November does.
176
+
177
+ ### 6. Retry a failed handler, then stop
178
+
179
+ If the handler raises, the job goes back to `pending` with `run_at` pushed forward (60s, 120s, 240s, … capped at an hour). After `max_attempts` (default 5) it becomes `failed` and stays that way so you can look at `last_error`.
180
+
181
+ **Why:** Networks blip. The payment API is down for two minutes. Automatic retries cover that. They should not retry forever and they should not hide the failure. A missing handler (you deployed a job name the worker does not know) is marked `failed` immediately, not retried. That is a sharp edge during rolling deploys. See below.
182
+
183
+ ### 7. Recover a worker that died mid-job
184
+
185
+ When a job is claimed, `locked_at` is set. If the process is killed, the row stays `running`. After `reclaim_after` (15 minutes by default), another worker sets it back to `pending` and someone else can take it.
186
+
187
+ **Why:** This is the correct use of a visibility timeout. It answers “how long do we wait before assuming this in-flight run is dead?” It does **not** answer “how far ahead may I schedule?” Those are different questions. Celery-on-Redis conflates them. deferjob does not. A job scheduled for November does not need a three-month timeout.
188
+
189
+ ---
190
+
191
+ ## The life of a job
192
+
193
+ ```text
194
+ schedule() ──► pending ──► running ──► done
195
+ │ │
196
+ │ ├── handler error, attempts left ──► pending (later)
197
+ │ └── handler error, no attempts left ──► failed
198
+ │
199
+ └── cancel() ──► cancelled
200
+
201
+ reschedule() only while pending
202
+ ```
203
+
204
+ | Status | Meaning | You can cancel / reschedule? |
205
+ | ----------- | -------------------------------------------- | ---------------------------- |
206
+ | `pending` | Waiting for `run_at` | Yes |
207
+ | `running` | A worker has claimed it | No |
208
+ | `done` | Handler returned without raising | No |
209
+ | `failed` | Handler raised until `max_attempts`, or no handler is registered | No |
210
+ | `cancelled` | You called `cancel` while it was pending | No |
211
+
212
+ `get(key=...)` only returns a job that is still `pending` or `running`: the live one for that key. History (`done`, `failed`, `cancelled`) is still in the table; use `list(key=..., status="done")` or SQL.
213
+
214
+ The same `key` can be scheduled again after the previous row is `done` / `failed` / `cancelled`. The unique index only covers live rows.
215
+
216
+ ---
217
+
218
+ ## How the worker claims a row
219
+
220
+ This is the query. It is the whole trick.
221
+
222
+ ```sql
223
+ UPDATE defer_jobs
224
+ SET status = 'running', attempts = attempts + 1, locked_at = now()
225
+ WHERE id = (
226
+ SELECT id FROM defer_jobs
227
+ WHERE status = 'pending' AND run_at <= now()
228
+ ORDER BY run_at, id
229
+ FOR UPDATE SKIP LOCKED
230
+ LIMIT 1
231
+ )
232
+ RETURNING *;
233
+ ```
234
+
235
+ `SKIP LOCKED` means “if another worker already has this row in a transaction, do not wait, take the next one.” Postgres shipped that in 9.5.
236
+
237
+ The future work is never loaded into the worker until it is due. Ten workers and twenty November events does not mean twenty Python objects living in RAM from August.
238
+
239
+ ---
240
+
241
+ ## At least once, not exactly once
242
+
243
+ Everything that runs jobs this way can deliver **at least once**:
244
+
245
+ - The worker crashes after the handler committed a payment but before the row was marked `done`. Reclaim runs the handler again.
246
+ - Two workers overlap if a job runs longer than `reclaim_after`.
247
+ - A retry runs after a failure you thought had succeeded.
248
+
249
+ deferjob will not save you from a double payout. Your handler must look at current state and refuse to act twice:
250
+
251
+ ```python
252
+ @jobs.job("complete_order")
253
+ def complete_order(job: Job) -> None:
254
+ order = orders.get(job.payload["order_id"])
255
+ if not order.is_live or order.dispute:
256
+ return
257
+ order.complete()
258
+ ```
259
+
260
+ That check is not polish. It is the line between “the job ran twice” and “we paid the chef twice.” Write it on purpose.
261
+
262
+ ---
263
+
264
+ ## The table
265
+
266
+ `jobs.install()` creates this. For a real app, copy `install_sql()` into your own migrations so the table is versioned with the rest of the schema.
267
+
268
+ ```sql
269
+ CREATE TABLE defer_jobs (
270
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
271
+ name text NOT NULL, -- handler to run
272
+ key text, -- optional business name
273
+ run_at timestamptz NOT NULL, -- when it becomes due
274
+ status text NOT NULL DEFAULT 'pending',
275
+ attempts integer NOT NULL DEFAULT 0,
276
+ max_attempts integer NOT NULL DEFAULT 5,
277
+ payload jsonb NOT NULL DEFAULT jsonb_build_object(),
278
+ last_error text,
279
+ created_at timestamptz NOT NULL DEFAULT now(),
280
+ updated_at timestamptz NOT NULL DEFAULT now(),
281
+ locked_at timestamptz -- set while running
282
+ );
283
+ ```
284
+
285
+ Indexes:
286
+
287
+ - pending rows by `run_at` (so “what is due?” is cheap)
288
+ - unique `key` while status is `pending` or `running`
289
+ - `locked_at` on running rows (so reclaim is cheap)
290
+
291
+ Default table name is `defer_jobs`. You can pass `table="..."` if you need another name. It must be a simple identifier, not `schema.table`.
292
+
293
+ ---
294
+
295
+ ## API in short
296
+
297
+ | You want to… | Call |
298
+ | ------------------------------------ | ----------------------------------------- |
299
+ | Create the table | `jobs.install()` or `install_sql()` |
300
+ | Register a handler | `@jobs.job("close_event")` |
301
+ | Schedule work | `jobs.schedule(name, run_at=..., ...)` |
302
+ | Replace an existing pending job | `schedule(..., key=..., replace=True)` |
303
+ | Move the time | `jobs.reschedule(key=..., run_at=...)` |
304
+ | Stop a pending job | `jobs.cancel(key=...)` |
305
+ | Read the live job | `jobs.get(key=...)` or `get(id=...)` |
306
+ | List by booking, status, etc. | `jobs.list(key_prefix=..., status=...)` |
307
+ | Run due jobs once (tests, cron) | `jobs.run_once()` |
308
+ | Run until the process is stopped | `jobs.run()` or `deferjob worker` |
309
+
310
+ `schedule` / `cancel` / `reschedule` / `get` / `list` all take optional `conn=` so they can share your transaction.
311
+
312
+ Errors you will see:
313
+
314
+ | Error | When |
315
+ | --------------- | ------------------------------------------------- |
316
+ | `JobExists` | That `key` already has a pending or running job |
317
+ | `JobNotFound` | No matching id, or no live job for that key |
318
+ | `JobNotPending` | You tried to cancel or move a job that is not waiting |
319
+ | `UnknownJob` | You asked for a handler name that was never registered |
320
+ | `NotConfigured` | No connection string and no `connect=` |
321
+
322
+ ---
323
+
324
+ ## What it cannot do
325
+
326
+ This list is the product boundary, plus the current limits of this library. Read it before you put money on the path.
327
+
328
+ ### It is not a queue for work that should run now
329
+
330
+ If the user clicks “export CSV” and a worker should start in the next second, use a queue (Celery without far-future `eta`, RQ, SQS, Postgres listen/notify queues, …). deferjob will do it if you set `run_at` to now, but it polls, runs one job after another in a single process, and opens a new database connection for each step. That is the wrong shape for a hot path.
331
+
332
+ ### It is not exactly-once
333
+
334
+ See above. Handlers must be idempotent. There is no distributed transaction around “your side effect + mark the job done.”
335
+
336
+ ### It cannot stop a job that has already started
337
+
338
+ `cancel` only works in `pending`. If the worker has claimed `complete_order` and a dispute lands in the same minute, cancel raises `JobNotPending` and the handler still runs. The handler has to check `order.dispute` itself.
339
+
340
+ `cancel` and `reschedule` are also not one atomic `UPDATE ... WHERE status = 'pending'`. A claim can sneak in between the read and the write. Do not treat cancel as a lock on the business action.
341
+
342
+ ### It cannot promise a job will survive a rolling deploy unchanged
343
+
344
+ If you enqueue `name="payout_v2"` and an old worker is still running, that worker does not have the handler. deferjob marks the job **failed immediately** (no retries). A new job name and an old worker is a lost job unless you drain workers or keep the old handler registered.
345
+
346
+ A job queued in August always runs against **November’s code**. That is a feature (you can fix the handler) and a constraint (do not put positional arguments you will rename into `payload` and then forget). Keep payloads as stable ids.
347
+
348
+ ### It cannot run a hung handler forever, but it also cannot stop one
349
+
350
+ There is no per-job timeout. A handler that blocks on a network call sits in `running` until `reclaim_after` (15 minutes). Then a second worker may start the same job while the first is still going. Your handler must tolerate that overlap.
351
+
352
+ ### It cannot hide Postgres from you
353
+
354
+ You need a database you already trust. deferjob does not ship:
355
+
356
+ - connection pooling on the default path (each API call may open and close a connection; pass `connect=` with a pool if you care)
357
+ - async
358
+ - Django or SQLAlchemy session helpers (you can pass the raw psycopg connection)
359
+ - a schema name (`app.defer_jobs`)
360
+ - automatic purging of old `done` / `failed` / `cancelled` rows (the table grows until you delete them)
361
+ - versioned migrations (only `CREATE TABLE IF NOT EXISTS`)
362
+ - metrics, an admin UI, or alerts when a job fails
363
+ - `LISTEN/NOTIFY` (the worker wakes on a timer, not when you insert a near-term job)
364
+ - cron / repeating jobs (one `run_at` per row; to repeat, the handler schedules the next one)
365
+ - workflows (wait, then branch, then wait again). That is Step Functions or Temporal. A dispute with several deadlines is several rows, or one row that reschedules itself.
366
+
367
+ ### It cannot replace “wake me up”
368
+
369
+ Celery Beat, systemd timers, EventBridge, or Kubernetes cron can start or poke the worker. They should not *store* the November wedding. The schedule stays in the table. Something still has to run `jobs.run()` in a process that stays up.
370
+
371
+ ### It cannot make a bad handler safe
372
+
373
+ If `complete_order` pays the chef without checking state, a retry or a reclaim double-pays. The library will not notice. That is the same rule as SQS, Celery, and EventBridge.
374
+
375
+ ---
376
+
377
+ ## When this is the right tool
378
+
379
+ Use it when:
380
+
381
+ - The work is tied to a row you already keep in Postgres (booking, order, dispute).
382
+ - The delay is long enough that a process restart is certain (hours to months).
383
+ - You need to change or cancel the work as data changes.
384
+ - A minute late is acceptable.
385
+ - The handler can check “is this still the right thing to do?”
386
+
387
+ Chef Galaxy’s event close, order complete, accept-or-reject, and quiet-dispute timers are that shape.
388
+
389
+ Do not use it when you need sub-second dispatch, exactly-once side effects, multi-step sagas, or a high-throughput queue. Use the tool that is for that.
390
+
391
+ ---
392
+
393
+ ## Install and run
394
+
395
+ ```bash
396
+ pip install deferjob
397
+ ```
398
+
399
+ ```python
400
+ # myapp/jobs.py
401
+ from deferjob import Defer, Job
402
+
403
+ jobs = Defer("postgresql://localhost/app")
404
+
405
+ @jobs.job("close_event")
406
+ def close_event(job: Job) -> None:
407
+ ...
408
+ ```
409
+
410
+ ```bash
411
+ deferjob install --dsn postgresql://localhost/app
412
+ deferjob worker --app myapp.jobs:jobs
413
+ ```
414
+
415
+ `Defer(conninfo)` or `Defer(connect=pool.connection)` if you already have a pool. `jobs.configure(dsn)` is there for app factories that register handlers before they have a URL.
416
+
417
+ ---
418
+
419
+ ## Develop
420
+
421
+ ```bash
422
+ python -m venv .venv
423
+ source .venv/bin/activate
424
+ pip install -e ".[dev]"
425
+ pytest
426
+ ```
427
+
428
+ Tests start Postgres with Docker unless `DATABASE_URL` is set.
429
+
430
+ Releases publish to PyPI through GitHub Actions (Trusted Publishing). Bump the version, then create a GitHub release named `v0.1.0` (or later). There is no upload token on a laptop.
431
+
432
+ ---
433
+
434
+ ## Status
435
+
436
+ This is **0.1.0**. The idea is old and boring on purpose. The package is young. The design is what you want for far-future work. The operations around it (pooling, atomic cancel, deploy-safe unknown handlers, timeouts, retention) are still thin. Treat it as a clear table and a small worker, not as an invisible platform.