without-durability-postgres 0.0.6__tar.gz → 0.0.8__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.
- {without_durability_postgres-0.0.6 → without_durability_postgres-0.0.8}/PKG-INFO +4 -3
- {without_durability_postgres-0.0.6 → without_durability_postgres-0.0.8}/README.md +2 -1
- {without_durability_postgres-0.0.6 → without_durability_postgres-0.0.8}/pyproject.toml +2 -2
- {without_durability_postgres-0.0.6 → without_durability_postgres-0.0.8}/pyproject.toml.orig +2 -2
- {without_durability_postgres-0.0.6 → without_durability_postgres-0.0.8}/src/without_durability_postgres/store.py +176 -4
- {without_durability_postgres-0.0.6 → without_durability_postgres-0.0.8}/src/without_durability_postgres/__init__.py +0 -0
- {without_durability_postgres-0.0.6 → without_durability_postgres-0.0.8}/src/without_durability_postgres/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: without-durability-postgres
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: A without-durability checkpoint store and queue backed by Postgres, where every guarantee is an ordinary transaction.
|
|
5
5
|
Author: Josh Karpel
|
|
6
6
|
Author-email: Josh Karpel <josh.karpel@gmail.com>
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Classifier: Topic :: Software Development :: Libraries
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
|
-
Requires-Dist: without-durability==0.0.
|
|
17
|
+
Requires-Dist: without-durability==0.0.8
|
|
18
18
|
Requires-Dist: psycopg[binary,pool]>=3.2
|
|
19
19
|
Requires-Python: >=3.14
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
@@ -22,7 +22,8 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
# without-durability-postgres
|
|
23
23
|
|
|
24
24
|
[`without-durability`](https://pypi.org/project/without-durability/)'s two
|
|
25
|
-
interfaces over one Postgres: three tables, and no mechanism of its
|
|
25
|
+
interfaces over one Postgres: three tables and a sequence, and no mechanism of its
|
|
26
|
+
own.
|
|
26
27
|
|
|
27
28
|
```python
|
|
28
29
|
from psycopg_pool import AsyncConnectionPool
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# without-durability-postgres
|
|
2
2
|
|
|
3
3
|
[`without-durability`](https://pypi.org/project/without-durability/)'s two
|
|
4
|
-
interfaces over one Postgres: three tables, and no mechanism of its
|
|
4
|
+
interfaces over one Postgres: three tables and a sequence, and no mechanism of its
|
|
5
|
+
own.
|
|
5
6
|
|
|
6
7
|
```python
|
|
7
8
|
from psycopg_pool import AsyncConnectionPool
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "without-durability-postgres"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.8"
|
|
8
8
|
description = "A without-durability checkpoint store and queue backed by Postgres, where every guarantee is an ordinary transaction."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -21,7 +21,7 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"without-durability==0.0.
|
|
24
|
+
"without-durability==0.0.8",
|
|
25
25
|
"psycopg[binary,pool]>=3.2",
|
|
26
26
|
]
|
|
27
27
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "without-durability-postgres"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.8"
|
|
8
8
|
description = "A without-durability checkpoint store and queue backed by Postgres, where every guarantee is an ordinary transaction."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -24,7 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Typing :: Typed",
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"without-durability==0.0.
|
|
27
|
+
"without-durability==0.0.8",
|
|
28
28
|
"psycopg[binary,pool]>=3.2",
|
|
29
29
|
]
|
|
30
30
|
|
|
@@ -62,11 +62,15 @@ from psycopg.rows import TupleRow
|
|
|
62
62
|
from psycopg_pool import AsyncConnectionPool
|
|
63
63
|
from without_durability.codec import JSON
|
|
64
64
|
from without_durability.codec import CheckpointCodec
|
|
65
|
+
from without_durability.interfaces import INBOX
|
|
66
|
+
from without_durability.interfaces import INBOX_DIGITS
|
|
65
67
|
from without_durability.interfaces import LEASE
|
|
66
68
|
from without_durability.interfaces import Delivery
|
|
69
|
+
from without_durability.interfaces import Entry
|
|
67
70
|
from without_durability.interfaces import Fenced
|
|
68
71
|
from without_durability.interfaces import Pass
|
|
69
72
|
from without_durability.interfaces import Recorded
|
|
73
|
+
from without_durability.interfaces import Written
|
|
70
74
|
from without_durability.interfaces import check_duration
|
|
71
75
|
from without_durability.stepwise import now_utc
|
|
72
76
|
|
|
@@ -96,13 +100,56 @@ POLL = timedelta(milliseconds=50)
|
|
|
96
100
|
# JSON null" distinguishable, so a step that legitimately records `None` is not read back
|
|
97
101
|
# as a step that never ran.
|
|
98
102
|
#
|
|
103
|
+
# `seq` is what `load`'s ordering guarantee rests on. The default is evaluated on insert
|
|
104
|
+
# and left alone by the conflict update below, which is exactly the property the guarantee
|
|
105
|
+
# needs: the writer that first recorded a step decides where it sits, and a later write
|
|
106
|
+
# that loses moves neither the value nor the position.
|
|
107
|
+
#
|
|
108
|
+
# It is deliberately not scoped to the workflow. Numbering per workflow would mean reading
|
|
109
|
+
# the current maximum on every write, and the contract is only about the order *within* one
|
|
110
|
+
# workflow, which a shared sequence satisfies with gaps.
|
|
111
|
+
#
|
|
112
|
+
# There is no physical order to fall back on, which is worth stating because a heap scan
|
|
113
|
+
# looks like insertion order right up until it is not: the conflict update is a real MVCC
|
|
114
|
+
# update, so it writes a new tuple version and moves the row.
|
|
115
|
+
#
|
|
116
|
+
# `written_at` is what `history` reads, and its `DEFAULT` is doing the same work `seq`'s
|
|
117
|
+
# does: evaluated on insert, and left alone by every conflict update below, so a losing
|
|
118
|
+
# write moves the value, the position, and the time equally not at all. The clock is
|
|
119
|
+
# `clock_timestamp()` and not the `now()` every other statement here reads, which is the
|
|
120
|
+
# one place this file wants a time that is not the transaction's start: `transact` runs
|
|
121
|
+
# its effect *inside* the transaction, so a step that spent ten seconds at a gateway would
|
|
122
|
+
# be stamped ten seconds before it landed, and could carry an earlier time than a `supply`
|
|
123
|
+
# that committed while it ran and took a lower `seq`, which is `history` returning its
|
|
124
|
+
# records in one order and their times in another. Both are the *server's* clock, which is
|
|
125
|
+
# what makes two records' times comparable across the machines that wrote them, and is the
|
|
126
|
+
# same clock the claim's lease is measured by.
|
|
127
|
+
#
|
|
128
|
+
# `workflow_seq` is a named sequence with a `DEFAULT` rather than an identity column,
|
|
129
|
+
# because `append` has to mint a key from the *same* number that becomes the row's
|
|
130
|
+
# position, and an identity column is a number no statement is allowed to see. Two
|
|
131
|
+
# sequences cannot do it: the inbox key and the position would be drawn separately, so two
|
|
132
|
+
# concurrent appends could take them in opposite orders and `load` would render the pair
|
|
133
|
+
# backwards against keys that sort the other way. One number is both, so the two orders
|
|
134
|
+
# cannot disagree. `nextval` is atomic and never hands the same number out twice, which is
|
|
135
|
+
# what makes an inbox key safe to mint under concurrent writers where `MAX(...) + 1` inside
|
|
136
|
+
# a statement is a race two inserts can both win, with the loser's message vanishing into
|
|
137
|
+
# first-writer-wins and no error to show for it. It is shared across workflows and it skips
|
|
138
|
+
# numbers on rollback, so any one workflow's keys have gaps; the contract asks only that
|
|
139
|
+
# they sort into append order within a workflow, which is exactly what a shared counter
|
|
140
|
+
# gives.
|
|
141
|
+
#
|
|
99
142
|
# The index is the one query that matters for throughput, `next_ready`'s scan for the
|
|
100
143
|
# oldest visible row in a namespace. The other two tables are read by primary key.
|
|
101
144
|
SCHEMA = """
|
|
145
|
+
CREATE SEQUENCE IF NOT EXISTS workflow_seq;
|
|
146
|
+
|
|
102
147
|
CREATE TABLE IF NOT EXISTS workflow_checkpoint (
|
|
103
148
|
workflow text NOT NULL,
|
|
104
149
|
step text NOT NULL,
|
|
105
150
|
value jsonb NOT NULL,
|
|
151
|
+
seq bigint NOT NULL DEFAULT nextval('workflow_seq'),
|
|
152
|
+
written_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
106
153
|
PRIMARY KEY (workflow, step)
|
|
107
154
|
);
|
|
108
155
|
|
|
@@ -191,6 +238,32 @@ ON CONFLICT (workflow, step) DO UPDATE SET value = recorded.value
|
|
|
191
238
|
RETURNING recorded.value::text
|
|
192
239
|
"""
|
|
193
240
|
|
|
241
|
+
# `supply` under a key this statement mints instead of one the caller brought: the append
|
|
242
|
+
# that puts a message in a workflow's inbox.
|
|
243
|
+
#
|
|
244
|
+
# The CTE is what draws one number and spends it twice, as the key and as the row's
|
|
245
|
+
# position, which is the whole of why the key order and the load order agree under
|
|
246
|
+
# concurrency. `nextval` in the `SELECT` list is evaluated once for the one row it
|
|
247
|
+
# produces, and both columns then read that value rather than calling the sequence again.
|
|
248
|
+
# Supplying `seq` explicitly is the reason the column carries a `DEFAULT` rather than being
|
|
249
|
+
# an identity: every other insert here leaves it out and takes the default.
|
|
250
|
+
#
|
|
251
|
+
# No `ON CONFLICT` clause, deliberately. `nextval` never repeats, so the key is fresh by
|
|
252
|
+
# construction and a conflict would mean the numbering is broken; a duplicate-key error is
|
|
253
|
+
# the loud version of that, where an upsert would quietly hand back somebody else's
|
|
254
|
+
# message.
|
|
255
|
+
APPEND = f"""
|
|
256
|
+
WITH minted AS (SELECT nextval('workflow_seq') AS seq)
|
|
257
|
+
INSERT INTO workflow_checkpoint AS entry (workflow, step, value, seq)
|
|
258
|
+
SELECT
|
|
259
|
+
%(workflow)s,
|
|
260
|
+
'{INBOX}' || lpad(minted.seq::text, {INBOX_DIGITS}, '0'),
|
|
261
|
+
%(value)s::jsonb,
|
|
262
|
+
minted.seq
|
|
263
|
+
FROM minted
|
|
264
|
+
RETURNING entry.step, entry.value::text
|
|
265
|
+
"""
|
|
266
|
+
|
|
194
267
|
# The three statements `transact` runs between `BEGIN` and `COMMIT`, with the effect's own
|
|
195
268
|
# work in the middle. They are separate strings rather than one because the effect is
|
|
196
269
|
# arbitrary application SQL that this store cannot see, which is precisely what makes the
|
|
@@ -210,7 +283,22 @@ ON CONFLICT (workflow, step) DO NOTHING
|
|
|
210
283
|
RETURNING value::text
|
|
211
284
|
"""
|
|
212
285
|
|
|
213
|
-
LOAD = "SELECT step, value::text FROM workflow_checkpoint WHERE workflow = %s"
|
|
286
|
+
LOAD = "SELECT step, value::text FROM workflow_checkpoint WHERE workflow = %s ORDER BY seq"
|
|
287
|
+
HISTORY = "SELECT step, value::text, written_at FROM workflow_checkpoint WHERE workflow = %s ORDER BY seq"
|
|
288
|
+
|
|
289
|
+
# Forget every record a workflow has. Paired with `SUPERSEDE` below and never run without
|
|
290
|
+
# it, which is what the transaction in `discard` is for.
|
|
291
|
+
DISCARD = "DELETE FROM workflow_checkpoint WHERE workflow = %s"
|
|
292
|
+
|
|
293
|
+
# Take the fencing token *up*, so a pass still holding one is refused at its next write.
|
|
294
|
+
#
|
|
295
|
+
# An `UPDATE` rather than the upsert `CLAIM` is, and the difference is what it declines to
|
|
296
|
+
# do: a workflow with no claim row has no `Pass` outstanding, since a `Pass` is only ever
|
|
297
|
+
# handed out by a `claim` that wrote one, so there is nothing to fence and a row minted
|
|
298
|
+
# here would be a tombstone for a workflow nobody ever claimed. `held_until = now()` hands
|
|
299
|
+
# the workflow back at the same time, so it is claimable again immediately: what is kept is
|
|
300
|
+
# the ordering, not the claim.
|
|
301
|
+
SUPERSEDE = "UPDATE workflow_claim SET token = token + 1, held_until = now() WHERE workflow = %s"
|
|
214
302
|
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
215
303
|
# number up and a pass that comes back from the dead still loses. Conditional on the
|
|
216
304
|
# token for the same reason `release` is in the Redis store: a superseded pass letting go
|
|
@@ -245,12 +333,12 @@ class Supplied(Exception):
|
|
|
245
333
|
|
|
246
334
|
async def migrate(pool: AsyncConnectionPool) -> None:
|
|
247
335
|
"""
|
|
248
|
-
Create the three tables
|
|
336
|
+
Create the three tables and the sequence behind `seq`, from every process, as often as it likes.
|
|
249
337
|
|
|
250
338
|
Idempotent by `IF NOT EXISTS` and safe against itself by the advisory lock, which is
|
|
251
339
|
the part that is easy to skip: concurrent `CREATE TABLE IF NOT EXISTS` is a
|
|
252
|
-
duplicate-key error on the system catalog rather than a no-op
|
|
253
|
-
booting together is exactly a race. `pg_advisory_xact_lock` is held to the end of the
|
|
340
|
+
duplicate-key error on the system catalog rather than a no-op (and `CREATE SEQUENCE IF
|
|
341
|
+
NOT EXISTS` is the same), and a fleet of workers booting together is exactly a race. `pg_advisory_xact_lock` is held to the end of the
|
|
254
342
|
surrounding transaction and released by the commit, so there is nothing to unlock.
|
|
255
343
|
|
|
256
344
|
Schema migration as a whole is not what this is. There is no versioning and no path
|
|
@@ -331,6 +419,15 @@ class PostgresCheckpointer:
|
|
|
331
419
|
await cursor.execute(LOAD, (workflow,))
|
|
332
420
|
return {step: self.codec.decode(encoded) for step, encoded in await cursor.fetchall()}
|
|
333
421
|
|
|
422
|
+
async def history(self, workflow: str) -> dict[str, Written]:
|
|
423
|
+
"""The same records `load` returns, each with the moment the server wrote it."""
|
|
424
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
425
|
+
await cursor.execute(HISTORY, (workflow,))
|
|
426
|
+
return {
|
|
427
|
+
step: Written(value=self.codec.decode(encoded), at=written_at)
|
|
428
|
+
for step, encoded, written_at in await cursor.fetchall()
|
|
429
|
+
}
|
|
430
|
+
|
|
334
431
|
async def claim(self, workflow: str, lease: timedelta) -> Pass | None:
|
|
335
432
|
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
336
433
|
await cursor.execute(CLAIM, {"workflow": workflow, "lease": lease})
|
|
@@ -432,6 +529,31 @@ class PostgresCheckpointer:
|
|
|
432
529
|
await cursor.execute(SUPPLY, {"workflow": workflow, "step": key, "value": self.codec.encode(value)})
|
|
433
530
|
return self.codec.decode(cast(tuple[str], await cursor.fetchone())[0])
|
|
434
531
|
|
|
532
|
+
async def append(self, workflow: str, value: object) -> Entry:
|
|
533
|
+
"""File `value` in this workflow's inbox, under the next key the sequence hands out."""
|
|
534
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
535
|
+
await cursor.execute(APPEND, {"workflow": workflow, "value": self.codec.encode(value)})
|
|
536
|
+
key, encoded = cast(tuple[str, str], await cursor.fetchone())
|
|
537
|
+
return Entry(key=key, value=self.codec.decode(encoded))
|
|
538
|
+
|
|
539
|
+
async def discard(self, workflow: str) -> int:
|
|
540
|
+
"""
|
|
541
|
+
Forget every record this workflow has, and raise its fence, in one transaction.
|
|
542
|
+
|
|
543
|
+
One commit rather than two statements, because the two are only right together: a
|
|
544
|
+
crash between them either leaves the records deleted with the fence unraised, so
|
|
545
|
+
the pass that was mid-flight writes them back one at a time, or the reverse, which
|
|
546
|
+
fences a live pass for a deletion that never happened.
|
|
547
|
+
|
|
548
|
+
What is left behind is one claim row carrying a number. Nothing here sweeps it, in
|
|
549
|
+
keeping with the rest of this store, where nothing expires and a control-plane
|
|
550
|
+
sweep is the deployment's homework.
|
|
551
|
+
"""
|
|
552
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
553
|
+
await cursor.execute(SUPERSEDE, (workflow,))
|
|
554
|
+
await cursor.execute(DISCARD, (workflow,))
|
|
555
|
+
return cursor.rowcount
|
|
556
|
+
|
|
435
557
|
async def release(self, holder: Pass) -> None:
|
|
436
558
|
async with self.pool.connection() as connection:
|
|
437
559
|
await connection.execute(RELEASE, (holder.workflow, holder.token))
|
|
@@ -495,6 +617,11 @@ ON CONFLICT (namespace, workflow) DO UPDATE SET visible_at = EXCLUDED.visible_at
|
|
|
495
617
|
# wrote a different `visible_at`, so the equality is the whole check.
|
|
496
618
|
FINISH = "DELETE FROM workflow_queue WHERE namespace = %s AND workflow = %s AND visible_at = %s"
|
|
497
619
|
|
|
620
|
+
# Withdraw the workflow's right to run, whatever its row currently means. Unconditional
|
|
621
|
+
# where `FINISH` compares the receipt, which is the difference between finishing a pass
|
|
622
|
+
# (leave anything that asked for another) and cancelling the workflow (leave nothing).
|
|
623
|
+
CANCEL = "DELETE FROM workflow_queue WHERE namespace = %s AND workflow = %s"
|
|
624
|
+
|
|
498
625
|
# Suspend until a deadline, under the same comparison and for the same reason. A workflow
|
|
499
626
|
# holds one row here, so writing the deadline unconditionally would land on top of a
|
|
500
627
|
# `make_ready` that arrived while the pass was ending and push a confirmation out to a
|
|
@@ -632,6 +759,23 @@ class PostgresScheduler:
|
|
|
632
759
|
"""Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
|
|
633
760
|
return None
|
|
634
761
|
|
|
762
|
+
async def cancel(self, workflow: str) -> None:
|
|
763
|
+
"""
|
|
764
|
+
Drop the workflow's row, whichever of the three things its `visible_at` means.
|
|
765
|
+
|
|
766
|
+
One `DELETE` covers queued, sleeping, and out with a worker, because this table
|
|
767
|
+
holds one row per workflow and the visibility is the only thing that differs
|
|
768
|
+
between them. That is the same collapse that leaves `wake_due` and `reclaim` with
|
|
769
|
+
nothing to do.
|
|
770
|
+
|
|
771
|
+
The half of `cancel` a queue sweep cannot reach comes free with it: a pass still in
|
|
772
|
+
flight answers with `wake_at`, which is an `UPDATE` conditional on the visibility
|
|
773
|
+
still being the one it took, and a deleted row has none. So the deadline it was
|
|
774
|
+
about to write updates nothing and a deleted workflow is not put back to sleep.
|
|
775
|
+
"""
|
|
776
|
+
async with self.pool.connection() as connection:
|
|
777
|
+
await connection.execute(CANCEL, (self.namespace, workflow))
|
|
778
|
+
|
|
635
779
|
async def done(self, delivery: Delivery) -> None:
|
|
636
780
|
"""
|
|
637
781
|
Drop the workflow, unless something asked for another pass while this one ran.
|
|
@@ -696,3 +840,31 @@ class PostgresDurable:
|
|
|
696
840
|
{"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": self.scheduler.now()},
|
|
697
841
|
)
|
|
698
842
|
return codec.decode(stored[0])
|
|
843
|
+
|
|
844
|
+
async def deliver(self, workflow: str, value: object) -> Entry:
|
|
845
|
+
"""Append the message and make the workflow ready, together or not at all."""
|
|
846
|
+
codec = self.checkpointer.codec
|
|
847
|
+
async with self.checkpointer.pool.connection() as connection, connection.cursor() as cursor:
|
|
848
|
+
await cursor.execute(APPEND, {"workflow": workflow, "value": codec.encode(value)})
|
|
849
|
+
key, encoded = cast(tuple[str, str], await cursor.fetchone())
|
|
850
|
+
await cursor.execute(
|
|
851
|
+
SCHEDULE,
|
|
852
|
+
{"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": self.scheduler.now()},
|
|
853
|
+
)
|
|
854
|
+
return Entry(key=key, value=codec.decode(encoded))
|
|
855
|
+
|
|
856
|
+
async def delete(self, workflow: str) -> int:
|
|
857
|
+
"""
|
|
858
|
+
Cancel the workflow's wakeups and forget its records, together or not at all.
|
|
859
|
+
|
|
860
|
+
Three statements in one commit, so the ordering `SplitDurable` has to reason about
|
|
861
|
+
does not arise: there is no window in which the records are gone and a wakeup is
|
|
862
|
+
not, and none in which the fence has been raised for a deletion that did not
|
|
863
|
+
happen. Which is the same thing `arrive` gets from this store and for the same
|
|
864
|
+
reason, one datastore.
|
|
865
|
+
"""
|
|
866
|
+
async with self.checkpointer.pool.connection() as connection, connection.cursor() as cursor:
|
|
867
|
+
await cursor.execute(CANCEL, (self.scheduler.namespace, workflow))
|
|
868
|
+
await cursor.execute(SUPERSEDE, (workflow,))
|
|
869
|
+
await cursor.execute(DISCARD, (workflow,))
|
|
870
|
+
return cursor.rowcount
|
|
File without changes
|
|
File without changes
|