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.
- {without_durability_postgres-0.0.7 → without_durability_postgres-0.0.8}/PKG-INFO +2 -2
- {without_durability_postgres-0.0.7 → without_durability_postgres-0.0.8}/pyproject.toml +2 -2
- {without_durability_postgres-0.0.7 → without_durability_postgres-0.0.8}/pyproject.toml.orig +2 -2
- {without_durability_postgres-0.0.7 → without_durability_postgres-0.0.8}/src/without_durability_postgres/store.py +94 -0
- {without_durability_postgres-0.0.7 → without_durability_postgres-0.0.8}/README.md +0 -0
- {without_durability_postgres-0.0.7 → without_durability_postgres-0.0.8}/src/without_durability_postgres/__init__.py +0 -0
- {without_durability_postgres-0.0.7 → 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
|
|
@@ -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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|