without-durability-postgres 0.0.7__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.7
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.7
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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-durability-postgres"
7
- version = "0.0.7"
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.7",
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"
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.7",
27
+ "without-durability==0.0.8",
28
28
  "psycopg[binary,pool]>=3.2",
29
29
  ]
30
30
 
@@ -70,6 +70,7 @@ from without_durability.interfaces import Entry
70
70
  from without_durability.interfaces import Fenced
71
71
  from without_durability.interfaces import Pass
72
72
  from without_durability.interfaces import Recorded
73
+ from without_durability.interfaces import Written
73
74
  from without_durability.interfaces import check_duration
74
75
  from without_durability.stepwise import now_utc
75
76
 
@@ -112,6 +113,18 @@ POLL = timedelta(milliseconds=50)
112
113
  # looks like insertion order right up until it is not: the conflict update is a real MVCC
113
114
  # update, so it writes a new tuple version and moves the row.
114
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
+ #
115
128
  # `workflow_seq` is a named sequence with a `DEFAULT` rather than an identity column,
116
129
  # because `append` has to mint a key from the *same* number that becomes the row's
117
130
  # position, and an identity column is a number no statement is allowed to see. Two
@@ -136,6 +149,7 @@ CREATE TABLE IF NOT EXISTS workflow_checkpoint (
136
149
  step text NOT NULL,
137
150
  value jsonb NOT NULL,
138
151
  seq bigint NOT NULL DEFAULT nextval('workflow_seq'),
152
+ written_at timestamptz NOT NULL DEFAULT clock_timestamp(),
139
153
  PRIMARY KEY (workflow, step)
140
154
  );
141
155
 
@@ -270,6 +284,21 @@ RETURNING value::text
270
284
  """
271
285
 
272
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"
273
302
  # Hand the workflow back early, but keep the token, so the next claim gets the next
274
303
  # number up and a pass that comes back from the dead still loses. Conditional on the
275
304
  # token for the same reason `release` is in the Redis store: a superseded pass letting go
@@ -390,6 +419,15 @@ class PostgresCheckpointer:
390
419
  await cursor.execute(LOAD, (workflow,))
391
420
  return {step: self.codec.decode(encoded) for step, encoded in await cursor.fetchall()}
392
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
+
393
431
  async def claim(self, workflow: str, lease: timedelta) -> Pass | None:
394
432
  async with self.pool.connection() as connection, connection.cursor() as cursor:
395
433
  await cursor.execute(CLAIM, {"workflow": workflow, "lease": lease})
@@ -498,6 +536,24 @@ class PostgresCheckpointer:
498
536
  key, encoded = cast(tuple[str, str], await cursor.fetchone())
499
537
  return Entry(key=key, value=self.codec.decode(encoded))
500
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
+
501
557
  async def release(self, holder: Pass) -> None:
502
558
  async with self.pool.connection() as connection:
503
559
  await connection.execute(RELEASE, (holder.workflow, holder.token))
@@ -561,6 +617,11 @@ ON CONFLICT (namespace, workflow) DO UPDATE SET visible_at = EXCLUDED.visible_at
561
617
  # wrote a different `visible_at`, so the equality is the whole check.
562
618
  FINISH = "DELETE FROM workflow_queue WHERE namespace = %s AND workflow = %s AND visible_at = %s"
563
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
+
564
625
  # Suspend until a deadline, under the same comparison and for the same reason. A workflow
565
626
  # holds one row here, so writing the deadline unconditionally would land on top of a
566
627
  # `make_ready` that arrived while the pass was ending and push a confirmation out to a
@@ -698,6 +759,23 @@ class PostgresScheduler:
698
759
  """Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
699
760
  return None
700
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
+
701
779
  async def done(self, delivery: Delivery) -> None:
702
780
  """
703
781
  Drop the workflow, unless something asked for another pass while this one ran.
@@ -774,3 +852,19 @@ class PostgresDurable:
774
852
  {"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": self.scheduler.now()},
775
853
  )
776
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