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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: without-durability-postgres
3
- Version: 0.0.5
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.5
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 own.
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 own.
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.5"
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.5",
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.5"
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.5",
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, from every process, as often as it likes.
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, and a fleet of workers
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))