without-durability-postgres 0.0.5__tar.gz → 0.0.7__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.5 → without_durability_postgres-0.0.7}/PKG-INFO +4 -3
- {without_durability_postgres-0.0.5 → without_durability_postgres-0.0.7}/README.md +2 -1
- {without_durability_postgres-0.0.5 → without_durability_postgres-0.0.7}/pyproject.toml +2 -2
- {without_durability_postgres-0.0.5 → without_durability_postgres-0.0.7}/pyproject.toml.orig +2 -2
- {without_durability_postgres-0.0.5 → without_durability_postgres-0.0.7}/src/without_durability_postgres/store.py +82 -4
- {without_durability_postgres-0.0.5 → without_durability_postgres-0.0.7}/src/without_durability_postgres/__init__.py +0 -0
- {without_durability_postgres-0.0.5 → without_durability_postgres-0.0.7}/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.7
|
|
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.7
|
|
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.7"
|
|
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.7",
|
|
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.7"
|
|
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.7",
|
|
28
28
|
"psycopg[binary,pool]>=3.2",
|
|
29
29
|
]
|
|
30
30
|
|
|
@@ -62,8 +62,11 @@ 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
|
|
@@ -96,13 +99,43 @@ POLL = timedelta(milliseconds=50)
|
|
|
96
99
|
# JSON null" distinguishable, so a step that legitimately records `None` is not read back
|
|
97
100
|
# as a step that never ran.
|
|
98
101
|
#
|
|
102
|
+
# `seq` is what `load`'s ordering guarantee rests on. The default is evaluated on insert
|
|
103
|
+
# and left alone by the conflict update below, which is exactly the property the guarantee
|
|
104
|
+
# needs: the writer that first recorded a step decides where it sits, and a later write
|
|
105
|
+
# that loses moves neither the value nor the position.
|
|
106
|
+
#
|
|
107
|
+
# It is deliberately not scoped to the workflow. Numbering per workflow would mean reading
|
|
108
|
+
# the current maximum on every write, and the contract is only about the order *within* one
|
|
109
|
+
# workflow, which a shared sequence satisfies with gaps.
|
|
110
|
+
#
|
|
111
|
+
# There is no physical order to fall back on, which is worth stating because a heap scan
|
|
112
|
+
# looks like insertion order right up until it is not: the conflict update is a real MVCC
|
|
113
|
+
# update, so it writes a new tuple version and moves the row.
|
|
114
|
+
#
|
|
115
|
+
# `workflow_seq` is a named sequence with a `DEFAULT` rather than an identity column,
|
|
116
|
+
# because `append` has to mint a key from the *same* number that becomes the row's
|
|
117
|
+
# position, and an identity column is a number no statement is allowed to see. Two
|
|
118
|
+
# sequences cannot do it: the inbox key and the position would be drawn separately, so two
|
|
119
|
+
# concurrent appends could take them in opposite orders and `load` would render the pair
|
|
120
|
+
# backwards against keys that sort the other way. One number is both, so the two orders
|
|
121
|
+
# cannot disagree. `nextval` is atomic and never hands the same number out twice, which is
|
|
122
|
+
# what makes an inbox key safe to mint under concurrent writers where `MAX(...) + 1` inside
|
|
123
|
+
# a statement is a race two inserts can both win, with the loser's message vanishing into
|
|
124
|
+
# first-writer-wins and no error to show for it. It is shared across workflows and it skips
|
|
125
|
+
# numbers on rollback, so any one workflow's keys have gaps; the contract asks only that
|
|
126
|
+
# they sort into append order within a workflow, which is exactly what a shared counter
|
|
127
|
+
# gives.
|
|
128
|
+
#
|
|
99
129
|
# The index is the one query that matters for throughput, `next_ready`'s scan for the
|
|
100
130
|
# oldest visible row in a namespace. The other two tables are read by primary key.
|
|
101
131
|
SCHEMA = """
|
|
132
|
+
CREATE SEQUENCE IF NOT EXISTS workflow_seq;
|
|
133
|
+
|
|
102
134
|
CREATE TABLE IF NOT EXISTS workflow_checkpoint (
|
|
103
135
|
workflow text NOT NULL,
|
|
104
136
|
step text NOT NULL,
|
|
105
137
|
value jsonb NOT NULL,
|
|
138
|
+
seq bigint NOT NULL DEFAULT nextval('workflow_seq'),
|
|
106
139
|
PRIMARY KEY (workflow, step)
|
|
107
140
|
);
|
|
108
141
|
|
|
@@ -191,6 +224,32 @@ ON CONFLICT (workflow, step) DO UPDATE SET value = recorded.value
|
|
|
191
224
|
RETURNING recorded.value::text
|
|
192
225
|
"""
|
|
193
226
|
|
|
227
|
+
# `supply` under a key this statement mints instead of one the caller brought: the append
|
|
228
|
+
# that puts a message in a workflow's inbox.
|
|
229
|
+
#
|
|
230
|
+
# The CTE is what draws one number and spends it twice, as the key and as the row's
|
|
231
|
+
# position, which is the whole of why the key order and the load order agree under
|
|
232
|
+
# concurrency. `nextval` in the `SELECT` list is evaluated once for the one row it
|
|
233
|
+
# produces, and both columns then read that value rather than calling the sequence again.
|
|
234
|
+
# Supplying `seq` explicitly is the reason the column carries a `DEFAULT` rather than being
|
|
235
|
+
# an identity: every other insert here leaves it out and takes the default.
|
|
236
|
+
#
|
|
237
|
+
# No `ON CONFLICT` clause, deliberately. `nextval` never repeats, so the key is fresh by
|
|
238
|
+
# construction and a conflict would mean the numbering is broken; a duplicate-key error is
|
|
239
|
+
# the loud version of that, where an upsert would quietly hand back somebody else's
|
|
240
|
+
# message.
|
|
241
|
+
APPEND = f"""
|
|
242
|
+
WITH minted AS (SELECT nextval('workflow_seq') AS seq)
|
|
243
|
+
INSERT INTO workflow_checkpoint AS entry (workflow, step, value, seq)
|
|
244
|
+
SELECT
|
|
245
|
+
%(workflow)s,
|
|
246
|
+
'{INBOX}' || lpad(minted.seq::text, {INBOX_DIGITS}, '0'),
|
|
247
|
+
%(value)s::jsonb,
|
|
248
|
+
minted.seq
|
|
249
|
+
FROM minted
|
|
250
|
+
RETURNING entry.step, entry.value::text
|
|
251
|
+
"""
|
|
252
|
+
|
|
194
253
|
# The three statements `transact` runs between `BEGIN` and `COMMIT`, with the effect's own
|
|
195
254
|
# work in the middle. They are separate strings rather than one because the effect is
|
|
196
255
|
# arbitrary application SQL that this store cannot see, which is precisely what makes the
|
|
@@ -210,7 +269,7 @@ ON CONFLICT (workflow, step) DO NOTHING
|
|
|
210
269
|
RETURNING value::text
|
|
211
270
|
"""
|
|
212
271
|
|
|
213
|
-
LOAD = "SELECT step, value::text FROM workflow_checkpoint WHERE workflow = %s"
|
|
272
|
+
LOAD = "SELECT step, value::text FROM workflow_checkpoint WHERE workflow = %s ORDER BY seq"
|
|
214
273
|
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
215
274
|
# number up and a pass that comes back from the dead still loses. Conditional on the
|
|
216
275
|
# token for the same reason `release` is in the Redis store: a superseded pass letting go
|
|
@@ -245,12 +304,12 @@ class Supplied(Exception):
|
|
|
245
304
|
|
|
246
305
|
async def migrate(pool: AsyncConnectionPool) -> None:
|
|
247
306
|
"""
|
|
248
|
-
Create the three tables
|
|
307
|
+
Create the three tables and the sequence behind `seq`, from every process, as often as it likes.
|
|
249
308
|
|
|
250
309
|
Idempotent by `IF NOT EXISTS` and safe against itself by the advisory lock, which is
|
|
251
310
|
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
|
|
311
|
+
duplicate-key error on the system catalog rather than a no-op (and `CREATE SEQUENCE IF
|
|
312
|
+
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
313
|
surrounding transaction and released by the commit, so there is nothing to unlock.
|
|
255
314
|
|
|
256
315
|
Schema migration as a whole is not what this is. There is no versioning and no path
|
|
@@ -432,6 +491,13 @@ class PostgresCheckpointer:
|
|
|
432
491
|
await cursor.execute(SUPPLY, {"workflow": workflow, "step": key, "value": self.codec.encode(value)})
|
|
433
492
|
return self.codec.decode(cast(tuple[str], await cursor.fetchone())[0])
|
|
434
493
|
|
|
494
|
+
async def append(self, workflow: str, value: object) -> Entry:
|
|
495
|
+
"""File `value` in this workflow's inbox, under the next key the sequence hands out."""
|
|
496
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
497
|
+
await cursor.execute(APPEND, {"workflow": workflow, "value": self.codec.encode(value)})
|
|
498
|
+
key, encoded = cast(tuple[str, str], await cursor.fetchone())
|
|
499
|
+
return Entry(key=key, value=self.codec.decode(encoded))
|
|
500
|
+
|
|
435
501
|
async def release(self, holder: Pass) -> None:
|
|
436
502
|
async with self.pool.connection() as connection:
|
|
437
503
|
await connection.execute(RELEASE, (holder.workflow, holder.token))
|
|
@@ -696,3 +762,15 @@ class PostgresDurable:
|
|
|
696
762
|
{"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": self.scheduler.now()},
|
|
697
763
|
)
|
|
698
764
|
return codec.decode(stored[0])
|
|
765
|
+
|
|
766
|
+
async def deliver(self, workflow: str, value: object) -> Entry:
|
|
767
|
+
"""Append the message and make the workflow ready, together or not at all."""
|
|
768
|
+
codec = self.checkpointer.codec
|
|
769
|
+
async with self.checkpointer.pool.connection() as connection, connection.cursor() as cursor:
|
|
770
|
+
await cursor.execute(APPEND, {"workflow": workflow, "value": codec.encode(value)})
|
|
771
|
+
key, encoded = cast(tuple[str, str], await cursor.fetchone())
|
|
772
|
+
await cursor.execute(
|
|
773
|
+
SCHEDULE,
|
|
774
|
+
{"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": self.scheduler.now()},
|
|
775
|
+
)
|
|
776
|
+
return Entry(key=key, value=codec.decode(encoded))
|
|
File without changes
|
|
File without changes
|