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