without-durability-postgres 0.0.0__tar.gz → 0.0.2__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.2/PKG-INFO +66 -0
- without_durability_postgres-0.0.2/README.md +45 -0
- without_durability_postgres-0.0.2/pyproject.toml +33 -0
- without_durability_postgres-0.0.2/pyproject.toml.orig +32 -0
- without_durability_postgres-0.0.2/src/without_durability_postgres/__init__.py +15 -0
- without_durability_postgres-0.0.2/src/without_durability_postgres/store.py +698 -0
- without_durability_postgres-0.0.0/PKG-INFO +0 -5
- without_durability_postgres-0.0.0/pyproject.toml +0 -9
- without_durability_postgres-0.0.0/pyproject.toml.orig +0 -9
- /without_durability_postgres-0.0.0/src/without_durability_postgres/__init__.py → /without_durability_postgres-0.0.2/src/without_durability_postgres/py.typed +0 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: without-durability-postgres
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A without-durability checkpoint store and queue backed by Postgres, where every guarantee is an ordinary transaction.
|
|
5
|
+
Author: Josh Karpel
|
|
6
|
+
Author-email: Josh Karpel <josh.karpel@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: without-durability==0.0.2
|
|
18
|
+
Requires-Dist: psycopg[binary,pool]>=3.2
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# without-durability-postgres
|
|
23
|
+
|
|
24
|
+
[`without-durability`](https://pypi.org/project/without-durability/)'s two
|
|
25
|
+
interfaces over one Postgres: three tables, and no mechanism of its own.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from psycopg_pool import AsyncConnectionPool
|
|
29
|
+
from without_durability_postgres import PostgresCheckpointer, PostgresDurable, PostgresScheduler, migrate
|
|
30
|
+
|
|
31
|
+
pool = AsyncConnectionPool(dsn, open=False)
|
|
32
|
+
await pool.open(wait=True)
|
|
33
|
+
await migrate(pool)
|
|
34
|
+
durable = PostgresDurable(PostgresCheckpointer(pool=pool), PostgresScheduler(pool=pool))
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
What is worth reading this package for is how little of it is mechanism. Every
|
|
38
|
+
write the Redis store needs a Lua script for is one statement here, or one
|
|
39
|
+
transaction, and neither is something this package supplies. Redis needs scripts
|
|
40
|
+
because it has no way to say "check this, then write that, and let nobody in
|
|
41
|
+
between"; SQL says it by default. The claim is an upsert whose `DO UPDATE` carries
|
|
42
|
+
a `WHERE` on the lease; the fenced record is one statement whose `FOR UPDATE` CTE
|
|
43
|
+
serializes it against a claim in flight; the queue takes with
|
|
44
|
+
`FOR UPDATE SKIP LOCKED`, so several workers polling one table fan out instead of
|
|
45
|
+
queueing on its head.
|
|
46
|
+
|
|
47
|
+
Three things that are live questions over Redis do not arise. A workflow id is a
|
|
48
|
+
query parameter rather than key structure, so it carries no constraints at all.
|
|
49
|
+
Nothing expires, so the TTL that can lose a suspended workflow is gone, and the
|
|
50
|
+
fencing token can be an ordinary counter. And a default Postgres commits
|
|
51
|
+
synchronously, so `record` returning means what the durable runner assumes it
|
|
52
|
+
means.
|
|
53
|
+
|
|
54
|
+
`SqlEffect` is a callback handed a cursor inside the open transaction, so a step's
|
|
55
|
+
own business write and its checkpoint commit together: exactly-once for that step,
|
|
56
|
+
over the application's own tables. `PostgresDurable` extends that to the interface
|
|
57
|
+
above, committing a value's arrival and the workflow's wakeup at once, which is
|
|
58
|
+
what makes "you need no second system" a claim this can actually make. It is also
|
|
59
|
+
where "one datastore" stops meaning "Postgres", since a transaction is local only
|
|
60
|
+
on a single node.
|
|
61
|
+
|
|
62
|
+
See the
|
|
63
|
+
[`without-durability-postgres` guide](https://without.help/without-durability-postgres/)
|
|
64
|
+
(with the [API reference](https://without.help/without-durability-postgres/reference/))
|
|
65
|
+
for the statements, the schema, what co-location a sharded deployment owes, and
|
|
66
|
+
what a deployment still owes anyway (a sweep, and a real migration tool).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# without-durability-postgres
|
|
2
|
+
|
|
3
|
+
[`without-durability`](https://pypi.org/project/without-durability/)'s two
|
|
4
|
+
interfaces over one Postgres: three tables, and no mechanism of its own.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from psycopg_pool import AsyncConnectionPool
|
|
8
|
+
from without_durability_postgres import PostgresCheckpointer, PostgresDurable, PostgresScheduler, migrate
|
|
9
|
+
|
|
10
|
+
pool = AsyncConnectionPool(dsn, open=False)
|
|
11
|
+
await pool.open(wait=True)
|
|
12
|
+
await migrate(pool)
|
|
13
|
+
durable = PostgresDurable(PostgresCheckpointer(pool=pool), PostgresScheduler(pool=pool))
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
What is worth reading this package for is how little of it is mechanism. Every
|
|
17
|
+
write the Redis store needs a Lua script for is one statement here, or one
|
|
18
|
+
transaction, and neither is something this package supplies. Redis needs scripts
|
|
19
|
+
because it has no way to say "check this, then write that, and let nobody in
|
|
20
|
+
between"; SQL says it by default. The claim is an upsert whose `DO UPDATE` carries
|
|
21
|
+
a `WHERE` on the lease; the fenced record is one statement whose `FOR UPDATE` CTE
|
|
22
|
+
serializes it against a claim in flight; the queue takes with
|
|
23
|
+
`FOR UPDATE SKIP LOCKED`, so several workers polling one table fan out instead of
|
|
24
|
+
queueing on its head.
|
|
25
|
+
|
|
26
|
+
Three things that are live questions over Redis do not arise. A workflow id is a
|
|
27
|
+
query parameter rather than key structure, so it carries no constraints at all.
|
|
28
|
+
Nothing expires, so the TTL that can lose a suspended workflow is gone, and the
|
|
29
|
+
fencing token can be an ordinary counter. And a default Postgres commits
|
|
30
|
+
synchronously, so `record` returning means what the durable runner assumes it
|
|
31
|
+
means.
|
|
32
|
+
|
|
33
|
+
`SqlEffect` is a callback handed a cursor inside the open transaction, so a step's
|
|
34
|
+
own business write and its checkpoint commit together: exactly-once for that step,
|
|
35
|
+
over the application's own tables. `PostgresDurable` extends that to the interface
|
|
36
|
+
above, committing a value's arrival and the workflow's wakeup at once, which is
|
|
37
|
+
what makes "you need no second system" a claim this can actually make. It is also
|
|
38
|
+
where "one datastore" stops meaning "Postgres", since a transaction is local only
|
|
39
|
+
on a single node.
|
|
40
|
+
|
|
41
|
+
See the
|
|
42
|
+
[`without-durability-postgres` guide](https://without.help/without-durability-postgres/)
|
|
43
|
+
(with the [API reference](https://without.help/without-durability-postgres/reference/))
|
|
44
|
+
for the statements, the schema, what co-location a sharded deployment owes, and
|
|
45
|
+
what a deployment still owes anyway (a sweep, and a real migration tool).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.29,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-durability-postgres"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "A without-durability checkpoint store and queue backed by Postgres, where every guarantee is an ordinary transaction."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.14"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
14
|
+
"Framework :: AsyncIO",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"without-durability==0.0.2",
|
|
25
|
+
"psycopg[binary,pool]>=3.2",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "Josh Karpel"
|
|
30
|
+
email = "josh.karpel@gmail.com"
|
|
31
|
+
|
|
32
|
+
[tool.uv.sources.without-durability]
|
|
33
|
+
workspace = true
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.29,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-durability-postgres"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "A without-durability checkpoint store and queue backed by Postgres, where every guarantee is an ordinary transaction."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Josh Karpel", email = "josh.karpel@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.14"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"without-durability==0.0.2",
|
|
28
|
+
"psycopg[binary,pool]>=3.2",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
without-durability = { workspace = true }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from without_durability_postgres.store import SCHEMA
|
|
2
|
+
from without_durability_postgres.store import PostgresCheckpointer
|
|
3
|
+
from without_durability_postgres.store import PostgresDurable
|
|
4
|
+
from without_durability_postgres.store import PostgresScheduler
|
|
5
|
+
from without_durability_postgres.store import SqlEffect
|
|
6
|
+
from without_durability_postgres.store import migrate
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SCHEMA",
|
|
10
|
+
"PostgresCheckpointer",
|
|
11
|
+
"PostgresDurable",
|
|
12
|
+
"PostgresScheduler",
|
|
13
|
+
"SqlEffect",
|
|
14
|
+
"migrate",
|
|
15
|
+
]
|
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
# The same two interfaces as the Redis store, over one Postgres. It is the other half of that
|
|
2
|
+
# argument, and putting the two side by side is the point: `Checkpointer` and `Scheduler`
|
|
3
|
+
# state the guarantees, and a store says how it reaches them, so a family of stores is
|
|
4
|
+
# not one good implementation and one compromise.
|
|
5
|
+
#
|
|
6
|
+
# What is worth reading this file *for* is how little of it is mechanism. Every write the
|
|
7
|
+
# Redis store needs a Lua script for is one statement here, or one transaction, and
|
|
8
|
+
# neither is a thing this app supplies: a transaction is what a relational database is
|
|
9
|
+
# for. Redis needs scripts because it has no way to say "check this, then write that, and
|
|
10
|
+
# let nobody in between"; SQL says it by default. So the interesting comparison is not
|
|
11
|
+
# "which store is better" but *where the atomic unit came from*, and here it came with
|
|
12
|
+
# the database.
|
|
13
|
+
#
|
|
14
|
+
# Three tables, one database:
|
|
15
|
+
#
|
|
16
|
+
# workflow_checkpoint one row per (workflow, step), the value as jsonb
|
|
17
|
+
# workflow_claim one row per workflow: whose pass it is, and until when
|
|
18
|
+
# workflow_queue one row per (namespace, workflow), scored by when it is visible
|
|
19
|
+
#
|
|
20
|
+
# The third is what makes this a real alternative rather than half of one, and what
|
|
21
|
+
# `PostgresDurable` spends: the queue write and the checkpoint write are one commit,
|
|
22
|
+
# which is the reason "you need no second system" is a claim Postgres can make and
|
|
23
|
+
# Redis-plus-something cannot.
|
|
24
|
+
#
|
|
25
|
+
# Two consequences fall out of SQL that are worth naming, because both were live
|
|
26
|
+
# questions in the Redis store and neither survives the move.
|
|
27
|
+
#
|
|
28
|
+
# A workflow id is a *parameter* here, never part of a key, so the constraints the Redis
|
|
29
|
+
# store asks of one (no braces, bounded length) have nothing to attach to. That is the
|
|
30
|
+
# tell the Redis store predicted: it was a property of building keys by concatenation,
|
|
31
|
+
# not of workflow ids.
|
|
32
|
+
#
|
|
33
|
+
# Nothing expires. Redis re-arms a TTL on every write, which sweeps finished workflows
|
|
34
|
+
# for free and costs the sharp edge that a workflow suspended longer than the TTL loses
|
|
35
|
+
# its checkpoint while its wakeup survives. Here the rows stay until something deletes
|
|
36
|
+
# them, so that failure is gone and a control-plane sweep is now homework. It also lets
|
|
37
|
+
# the fencing token be an ordinary counter rather than a hybrid logical clock: a token
|
|
38
|
+
# can only rewind if a claim row disappears, and here that happens only if a sweep
|
|
39
|
+
# deletes it, which is a policy this app chooses rather than a lifetime the store
|
|
40
|
+
# imposes.
|
|
41
|
+
#
|
|
42
|
+
# Namespacing is the connection's job, not the key's. A table name is already scoped by
|
|
43
|
+
# its schema and database, so two deployments sharing a server are two databases (or two
|
|
44
|
+
# `search_path`s in the DSN) rather than two prefixes. The queue keeps a `namespace`
|
|
45
|
+
# *column* because there the namespace separates queues rather than deployments, and as a
|
|
46
|
+
# column it is data, which is the same move as the workflow id.
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import asyncio
|
|
51
|
+
from collections.abc import Awaitable
|
|
52
|
+
from collections.abc import Callable
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from dataclasses import field
|
|
55
|
+
from datetime import datetime
|
|
56
|
+
from datetime import timedelta
|
|
57
|
+
from time import monotonic
|
|
58
|
+
from typing import cast
|
|
59
|
+
|
|
60
|
+
from psycopg import AsyncCursor
|
|
61
|
+
from psycopg.rows import TupleRow
|
|
62
|
+
from psycopg_pool import AsyncConnectionPool
|
|
63
|
+
from without_durability.codec import JSON
|
|
64
|
+
from without_durability.codec import CheckpointCodec
|
|
65
|
+
from without_durability.interfaces import LEASE
|
|
66
|
+
from without_durability.interfaces import Delivery
|
|
67
|
+
from without_durability.interfaces import Fenced
|
|
68
|
+
from without_durability.interfaces import Pass
|
|
69
|
+
from without_durability.interfaces import Recorded
|
|
70
|
+
from without_durability.interfaces import check_duration
|
|
71
|
+
from without_durability.stepwise import now_utc
|
|
72
|
+
|
|
73
|
+
# How often a worker with nothing to do asks again, which is the price of having no
|
|
74
|
+
# blocking read. It is restated rather than imported from the Redis store so that running
|
|
75
|
+
# this one pulls in no Redis client at all, which is the whole shape of the offer. The
|
|
76
|
+
# *lease* is not restated: it is `interfaces.LEASE`, because unlike the poll interval it has
|
|
77
|
+
# to agree with something outside this store (the checkpoint claim the worker takes for
|
|
78
|
+
# exactly as long).
|
|
79
|
+
POLL = timedelta(milliseconds=50)
|
|
80
|
+
|
|
81
|
+
# One DDL for one database, because it *is* one database. `value` is `jsonb` rather than
|
|
82
|
+
# `json` so it is stored parsed, which is what buys the indexing and the operators that
|
|
83
|
+
# let an operator with `psql` query a workflow's history rather than only read it.
|
|
84
|
+
#
|
|
85
|
+
# That is a *storage* decision, and it is worth separating from the codec, which is the
|
|
86
|
+
# boundary decision the caller injects. The column says the bytes are a JSON document and
|
|
87
|
+
# normalizes them; the codec says how a Python value becomes that document and comes back.
|
|
88
|
+
# So every value crosses the boundary as text with an explicit `::jsonb` going in and a
|
|
89
|
+
# `::text` coming out, rather than letting psycopg's adapter be a second, invisible codec
|
|
90
|
+
# underneath the injected one. The cost of the column type is that a `PostgresCheckpointer`
|
|
91
|
+
# constrains its codec to produce JSON *text*, which is a real narrowing next to Redis and
|
|
92
|
+
# SQLite; what still varies is the library and the value mapping, which is where the
|
|
93
|
+
# interesting codecs differ anyway.
|
|
94
|
+
#
|
|
95
|
+
# `NOT NULL` on `value` is not decoration: it is what keeps "no row" and "a row holding
|
|
96
|
+
# JSON null" distinguishable, so a step that legitimately records `None` is not read back
|
|
97
|
+
# as a step that never ran.
|
|
98
|
+
#
|
|
99
|
+
# The index is the one query that matters for throughput, `next_ready`'s scan for the
|
|
100
|
+
# oldest visible row in a namespace. The other two tables are read by primary key.
|
|
101
|
+
SCHEMA = """
|
|
102
|
+
CREATE TABLE IF NOT EXISTS workflow_checkpoint (
|
|
103
|
+
workflow text NOT NULL,
|
|
104
|
+
step text NOT NULL,
|
|
105
|
+
value jsonb NOT NULL,
|
|
106
|
+
PRIMARY KEY (workflow, step)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
CREATE TABLE IF NOT EXISTS workflow_claim (
|
|
110
|
+
workflow text PRIMARY KEY,
|
|
111
|
+
token bigint NOT NULL,
|
|
112
|
+
held_until timestamptz NOT NULL
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
CREATE TABLE IF NOT EXISTS workflow_queue (
|
|
116
|
+
namespace text NOT NULL,
|
|
117
|
+
workflow text NOT NULL,
|
|
118
|
+
visible_at timestamptz NOT NULL,
|
|
119
|
+
PRIMARY KEY (namespace, workflow)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
CREATE INDEX IF NOT EXISTS workflow_queue_visible_at ON workflow_queue (namespace, visible_at);
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
# An arbitrary constant, and the only thing about it that matters is that every process
|
|
126
|
+
# running this migration picks the same one. `CREATE TABLE IF NOT EXISTS` is not safe
|
|
127
|
+
# against itself: two of them racing on a fresh database is a duplicate-key error in the
|
|
128
|
+
# catalog rather than a no-op, and every worker runs the migration at boot.
|
|
129
|
+
MIGRATION_LOCK = 0x77_0F_10_2026
|
|
130
|
+
|
|
131
|
+
# Take the workflow if nobody holds it, and stamp the taking with the next number up.
|
|
132
|
+
#
|
|
133
|
+
# One statement, and every part of the Lua script it replaces is a clause of it. The
|
|
134
|
+
# `WHERE` on `DO UPDATE` is the "is it free" check: a conflicting row whose lease has not
|
|
135
|
+
# elapsed fails the predicate, so the update does not happen and `RETURNING` yields no
|
|
136
|
+
# row, which is how a lost race is reported. The insert arm covers a workflow nobody has
|
|
137
|
+
# ever claimed, and Postgres serializes two of those against each other on the primary
|
|
138
|
+
# key, so the loser waits and then takes the `DO UPDATE` path rather than both winning.
|
|
139
|
+
#
|
|
140
|
+
# The clock is `now()`, which is the server's and is the transaction's start time. The
|
|
141
|
+
# reasoning is the Redis store's: a lease compared against the claimant's own clock is
|
|
142
|
+
# only as good as the agreement between the two, which is exactly what fails when a
|
|
143
|
+
# machine is unhealthy enough to stall mid-pass.
|
|
144
|
+
CLAIM = """
|
|
145
|
+
INSERT INTO workflow_claim AS held (workflow, token, held_until)
|
|
146
|
+
VALUES (%(workflow)s, 1, now() + %(lease)s)
|
|
147
|
+
ON CONFLICT (workflow) DO UPDATE
|
|
148
|
+
SET token = held.token + 1, held_until = now() + %(lease)s
|
|
149
|
+
WHERE held.held_until <= now()
|
|
150
|
+
RETURNING token
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
# The fenced, conditional write, and the whole of `record` in one statement.
|
|
154
|
+
#
|
|
155
|
+
# The `FOR UPDATE` is doing real work rather than being belt-and-braces. Without it the
|
|
156
|
+
# fence is read from the statement's snapshot, so a claim committing a microsecond after
|
|
157
|
+
# the statement began would go unseen and a superseded pass's write would land. Taking
|
|
158
|
+
# the row lock makes this statement queue behind any claim in flight and then re-read the
|
|
159
|
+
# row it locked, so the token compared against is the newest one.
|
|
160
|
+
#
|
|
161
|
+
# The rest is `HSETNX` and its read-back, as one upsert. `DO UPDATE SET value = the value
|
|
162
|
+
# already there` is a write that changes nothing and therefore returns the row that was
|
|
163
|
+
# already stored, which is how a caller that lost the race learns the winner's value
|
|
164
|
+
# instead of carrying on with its own. A plain `DO NOTHING` would return no row at all
|
|
165
|
+
# and force a second read that a concurrent inserter could still beat.
|
|
166
|
+
#
|
|
167
|
+
# The second returned column is who won, which the caller cannot work out afterwards (see
|
|
168
|
+
# `Recorded`). Here the comparison is between `jsonb` values rather than text, which is
|
|
169
|
+
# the stronger of the two: it is semantic, so two encoders that order an object's keys
|
|
170
|
+
# differently still agree.
|
|
171
|
+
#
|
|
172
|
+
# returns the value stored after the call and whether it is this call's, or no row at
|
|
173
|
+
# all when the pass is fenced
|
|
174
|
+
RECORD = """
|
|
175
|
+
WITH fence AS (
|
|
176
|
+
SELECT token FROM workflow_claim WHERE workflow = %(workflow)s FOR UPDATE
|
|
177
|
+
)
|
|
178
|
+
INSERT INTO workflow_checkpoint AS recorded (workflow, step, value)
|
|
179
|
+
SELECT %(workflow)s, %(step)s, %(value)s::jsonb FROM fence WHERE fence.token <= %(token)s
|
|
180
|
+
ON CONFLICT (workflow, step) DO UPDATE SET value = recorded.value
|
|
181
|
+
RETURNING recorded.value::text, recorded.value = %(value)s::jsonb
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
# The same conditional write without the fence, for a value that comes from outside any
|
|
185
|
+
# pass. Deliberately not gated on a claim: an approval must not fail because a worker
|
|
186
|
+
# happens to be mid-pass, and first-writer-wins is the whole guarantee it needs.
|
|
187
|
+
SUPPLY = """
|
|
188
|
+
INSERT INTO workflow_checkpoint AS recorded (workflow, step, value)
|
|
189
|
+
VALUES (%(workflow)s, %(step)s, %(value)s::jsonb)
|
|
190
|
+
ON CONFLICT (workflow, step) DO UPDATE SET value = recorded.value
|
|
191
|
+
RETURNING recorded.value::text
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
# The three statements `transact` runs between `BEGIN` and `COMMIT`, with the effect's own
|
|
195
|
+
# work in the middle. They are separate strings rather than one because the effect is
|
|
196
|
+
# arbitrary application SQL that this store cannot see, which is precisely what makes the
|
|
197
|
+
# transaction worth having.
|
|
198
|
+
FENCE = "SELECT token FROM workflow_claim WHERE workflow = %s FOR UPDATE"
|
|
199
|
+
ALREADY = "SELECT value::text FROM workflow_checkpoint WHERE workflow = %s AND step = %s"
|
|
200
|
+
# `ON CONFLICT DO NOTHING` rather than a plain insert, because `supply` is deliberately not
|
|
201
|
+
# gated on the claim and so is the one writer this transaction's fence does not exclude. An
|
|
202
|
+
# approval landing between the `ALREADY` read and this write would otherwise turn a step
|
|
203
|
+
# into a duplicate-key error, which `transact` MUST not answer with: the step is recorded,
|
|
204
|
+
# so the contract is to hand back what is recorded. Returning no row says that happened,
|
|
205
|
+
# and the caller rolls the effect back rather than committing work whose record belongs to
|
|
206
|
+
# somebody else.
|
|
207
|
+
WRITE = """
|
|
208
|
+
INSERT INTO workflow_checkpoint (workflow, step, value) VALUES (%s, %s, %s::jsonb)
|
|
209
|
+
ON CONFLICT (workflow, step) DO NOTHING
|
|
210
|
+
RETURNING value::text
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
LOAD = "SELECT step, value::text FROM workflow_checkpoint WHERE workflow = %s"
|
|
214
|
+
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
215
|
+
# number up and a pass that comes back from the dead still loses. Conditional on the
|
|
216
|
+
# token for the same reason `release` is in the Redis store: a superseded pass letting go
|
|
217
|
+
# must not hand away a claim someone else is holding.
|
|
218
|
+
RELEASE = "UPDATE workflow_claim SET held_until = now() WHERE workflow = %s AND token = %s"
|
|
219
|
+
|
|
220
|
+
# What an effect is for a store whose datastore is a Postgres database: an async callback
|
|
221
|
+
# handed a cursor that is already inside `transact`'s transaction. The Redis store's is a
|
|
222
|
+
# Lua script and the in-memory double's is a function over its own dict; nothing is shared
|
|
223
|
+
# between the three but the position in `transact`.
|
|
224
|
+
#
|
|
225
|
+
# A callback rather than a statement-and-parameters pair, because the transaction is the
|
|
226
|
+
# unit and a caller may need several statements in it, may need to read before it writes,
|
|
227
|
+
# and may want ordinary Python between them. Whatever it returns is recorded as the step's
|
|
228
|
+
# value, so it MUST be something the store's codec encodes, and it MUST confine itself to
|
|
229
|
+
# the cursor it is handed: opening another connection puts the work outside the transaction
|
|
230
|
+
# and gives back exactly the at-least-once gap `transact` exists to close. Unlike Redis's
|
|
231
|
+
# `LuaEffect` it returns an ordinary Python value rather than an encoding, because it runs
|
|
232
|
+
# in this process where the codec is.
|
|
233
|
+
type SqlEffect = Callable[[AsyncCursor[TupleRow]], Awaitable[object]]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class Supplied(Exception):
|
|
237
|
+
"""
|
|
238
|
+
Something outside the pass recorded this step first, so the effect must not stand.
|
|
239
|
+
|
|
240
|
+
Control flow rather than a failure, and it never leaves `transact`: raising is how the
|
|
241
|
+
effect's transaction is rolled back, since the value to return is a value the *other*
|
|
242
|
+
writer committed and reading it is a separate transaction's job.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def migrate(pool: AsyncConnectionPool) -> None:
|
|
247
|
+
"""
|
|
248
|
+
Create the three tables, from every process, as often as it likes.
|
|
249
|
+
|
|
250
|
+
Idempotent by `IF NOT EXISTS` and safe against itself by the advisory lock, which is
|
|
251
|
+
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
|
|
254
|
+
surrounding transaction and released by the commit, so there is nothing to unlock.
|
|
255
|
+
|
|
256
|
+
Schema migration as a whole is not what this is. There is no versioning and no path
|
|
257
|
+
from one shape of these tables to another, which is the ordinary thing a deployment
|
|
258
|
+
would want and the ordinary tool (Alembic, sqitch, plain numbered SQL files) is where
|
|
259
|
+
it belongs.
|
|
260
|
+
"""
|
|
261
|
+
async with pool.connection() as connection:
|
|
262
|
+
await connection.execute("SELECT pg_advisory_xact_lock(%s)", (MIGRATION_LOCK,))
|
|
263
|
+
await connection.execute(SCHEMA)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True, slots=True)
|
|
267
|
+
class PostgresCheckpointer:
|
|
268
|
+
"""
|
|
269
|
+
A workflow's completed steps as rows in one table, and its claim as a row in another.
|
|
270
|
+
|
|
271
|
+
The `Checkpointer` implementation for the deployment that already has a Postgres, and
|
|
272
|
+
the one that can co-commit with the application's own tables, which is the capability
|
|
273
|
+
the whole `Effect` parameter exists for. `SqlEffect` is a callback over `transact`'s
|
|
274
|
+
open transaction, so a step whose effect is a write to *this* database happens exactly
|
|
275
|
+
once rather than at least once.
|
|
276
|
+
|
|
277
|
+
It holds a pool rather than a connection, because a pass is one short transaction and
|
|
278
|
+
several passes run at once: a worker with a pool of ten runs ten passes without them
|
|
279
|
+
queueing behind each other, and `next_ready`'s poll is not blocking a connection while
|
|
280
|
+
it waits. Call `migrate` once against the same pool before anything else, at the
|
|
281
|
+
entrypoint that built it.
|
|
282
|
+
|
|
283
|
+
The durability question `RedisCheckpointer` has to hedge on does not arise here.
|
|
284
|
+
`record` returning means the transaction committed, and a default Postgres has
|
|
285
|
+
`synchronous_commit` on, so the write is on disk and survives a crash of the server
|
|
286
|
+
rather than only of the client. That is exactly what `run_durably`'s reasoning about
|
|
287
|
+
the window between an effect and its record assumes.
|
|
288
|
+
|
|
289
|
+
A workflow id carries no constraints here at all, since it is bound as a query parameter
|
|
290
|
+
rather than parsed as key structure. Nothing here derives one id from another either,
|
|
291
|
+
so an application is free to name a workflow's sibling (a saga's rollback, say)
|
|
292
|
+
however it likes out of its own namespace.
|
|
293
|
+
|
|
294
|
+
`codec` is how a step's result becomes the document in a `jsonb` column and comes
|
|
295
|
+
back, defaulting to the stdlib's JSON. The column type narrows what a codec here may
|
|
296
|
+
be in a way it does not for the other two stores: it MUST render JSON *text*, because
|
|
297
|
+
that is what `jsonb` will accept. What that still leaves free is the library and the
|
|
298
|
+
value mapping, which is the part worth changing. What it MUST keep, as everywhere, is
|
|
299
|
+
the round trip.
|
|
300
|
+
|
|
301
|
+
The column narrows the *values* too, and this is the one place where "store it as
|
|
302
|
+
`jsonb`" is not free. `jsonb` holds a parsed document rather than the text it was
|
|
303
|
+
given, so what comes back is `jsonb`'s rendering of the value rather than the codec's,
|
|
304
|
+
and three things change with it:
|
|
305
|
+
|
|
306
|
+
- a number goes through `numeric`, so a step returning `1e16` is read back as the
|
|
307
|
+
integer `10000000000000000`. Above 2^53 it is not even the same number, since
|
|
308
|
+
`json.dumps` writes the shortest decimal that round-trips *as a float* and `numeric`
|
|
309
|
+
keeps that decimal exactly: `2.024478232766865e+16` returns as
|
|
310
|
+
`20244782327668650`, which is a different value and not merely a different type.
|
|
311
|
+
- keys are reordered by `jsonb`'s own rule (length, then bytes), so a mapping comes
|
|
312
|
+
back in an order the codec did not choose. Equality survives it; iteration order
|
|
313
|
+
does not, so a workflow that iterates a recorded mapping should sort it.
|
|
314
|
+
- a string `jsonb` cannot hold is refused outright rather than narrowed: a `NUL`
|
|
315
|
+
escape or a lone surrogate is valid JSON and valid to every other store here, and
|
|
316
|
+
`record` raises on the cast.
|
|
317
|
+
|
|
318
|
+
Nothing about the codec can repair any of it, since it happens after `encode` and
|
|
319
|
+
before `decode`. So the round trip a step result MUST survive here is `jsonb`'s and
|
|
320
|
+
not only JSON's. `run_durably` catches the first of the three rather than a comment,
|
|
321
|
+
by comparing what a node returned against what the store reads back, type included,
|
|
322
|
+
on the pass that wrote it; `Run.step`'s parser is where a stepwise workflow says what
|
|
323
|
+
it expects.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
pool: AsyncConnectionPool
|
|
327
|
+
codec: CheckpointCodec[str] = JSON
|
|
328
|
+
|
|
329
|
+
async def load(self, workflow: str) -> dict[str, object]:
|
|
330
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
331
|
+
await cursor.execute(LOAD, (workflow,))
|
|
332
|
+
return {step: self.codec.decode(encoded) for step, encoded in await cursor.fetchall()}
|
|
333
|
+
|
|
334
|
+
async def claim(self, workflow: str, lease: timedelta) -> Pass | None:
|
|
335
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
336
|
+
await cursor.execute(CLAIM, {"workflow": workflow, "lease": lease})
|
|
337
|
+
taken = await cursor.fetchone()
|
|
338
|
+
if taken is None:
|
|
339
|
+
return None
|
|
340
|
+
return Pass(workflow=workflow, token=cast(int, taken[0]))
|
|
341
|
+
|
|
342
|
+
async def record(self, holder: Pass, key: str, value: object) -> Recorded:
|
|
343
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
344
|
+
await cursor.execute(
|
|
345
|
+
RECORD,
|
|
346
|
+
{
|
|
347
|
+
"workflow": holder.workflow,
|
|
348
|
+
"step": key,
|
|
349
|
+
"value": self.codec.encode(value),
|
|
350
|
+
"token": holder.token,
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
stored = await cursor.fetchone()
|
|
354
|
+
if stored is None:
|
|
355
|
+
# The statement wrote nothing, which happens for exactly one reason: the
|
|
356
|
+
# `WHERE` that guards the insert compared this pass's token against the fence
|
|
357
|
+
# and refused it. (A missing claim row would land here too, and a `Pass` is
|
|
358
|
+
# only ever handed out by a `claim` that wrote one.)
|
|
359
|
+
raise Fenced(f"{holder.workflow!r} moved on while this pass held it")
|
|
360
|
+
return Recorded(value=self.codec.decode(cast(str, stored[0])), first=cast(bool, stored[1]))
|
|
361
|
+
|
|
362
|
+
async def transact(self, holder: Pass, key: str, effect: SqlEffect) -> object:
|
|
363
|
+
"""
|
|
364
|
+
Run `effect` and record it in one transaction, so the step happens once.
|
|
365
|
+
|
|
366
|
+
The order is the Lua script's, for the same reasons: fence first, because a
|
|
367
|
+
superseded pass must not act; then the *existence* check, because a step already
|
|
368
|
+
recorded must not run again, which is what makes a replay perform nothing at all;
|
|
369
|
+
then the effect; then the record. What differs is that none of it needed a
|
|
370
|
+
mechanism. `BEGIN` and `COMMIT` are the atomicity, the connection pool's context
|
|
371
|
+
manager is what issues them, and an exception anywhere inside (the fence, the
|
|
372
|
+
effect's own SQL, a constraint the effect violated) rolls the whole thing back
|
|
373
|
+
including the record.
|
|
374
|
+
|
|
375
|
+
The effect's result is written and read back through the column rather than
|
|
376
|
+
returned as it came, so it round-trips through the codec exactly as a later pass
|
|
377
|
+
will see it. A step that returns something `jsonb` renders differently (a tuple,
|
|
378
|
+
which comes back a list) then does so on the first pass rather than surprising the
|
|
379
|
+
second.
|
|
380
|
+
|
|
381
|
+
The fence is held for as long as the effect runs, which is what makes it a fence
|
|
382
|
+
and is worth stating because of what it costs elsewhere: `claim` takes the same
|
|
383
|
+
row, so a worker trying to take this workflow over *waits* for the effect rather
|
|
384
|
+
than being told the workflow is held, and each one that waits holds a pool
|
|
385
|
+
connection while it does. A long effect and a small pool is a worker that stops
|
|
386
|
+
pulling work for unrelated namespaces. Size the pool for the passes a deployment
|
|
387
|
+
runs concurrently plus the takeovers it expects, or keep the effects short.
|
|
388
|
+
|
|
389
|
+
The fence excludes every other *pass*, and one writer is left over: `supply` is
|
|
390
|
+
ungated on purpose, so an approval can land under this key between the read and the
|
|
391
|
+
write. That is what the retry below is for. The insert declines to overwrite, the
|
|
392
|
+
transaction rolls back so the effect goes with it, and the value that did land is
|
|
393
|
+
read and returned, which is what "the recorded value without re-running" means when
|
|
394
|
+
the recording was somebody else's. Rare enough to pay a second transaction for, and
|
|
395
|
+
it costs nothing on the path that wins.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
399
|
+
await cursor.execute(FENCE, (holder.workflow,))
|
|
400
|
+
fence = await cursor.fetchone()
|
|
401
|
+
if fence is None or holder.token < fence[0]:
|
|
402
|
+
raise Fenced(f"{holder.workflow!r} moved on while this pass held it")
|
|
403
|
+
await cursor.execute(ALREADY, (holder.workflow, key))
|
|
404
|
+
recorded = await cursor.fetchone()
|
|
405
|
+
if recorded is not None:
|
|
406
|
+
return self.codec.decode(cast(str, recorded[0]))
|
|
407
|
+
await cursor.execute(WRITE, (holder.workflow, key, self.codec.encode(await effect(cursor))))
|
|
408
|
+
written = await cursor.fetchone()
|
|
409
|
+
if written is None:
|
|
410
|
+
raise Supplied
|
|
411
|
+
return self.codec.decode(cast(str, written[0]))
|
|
412
|
+
except Supplied:
|
|
413
|
+
pass
|
|
414
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
415
|
+
await cursor.execute(ALREADY, (holder.workflow, key))
|
|
416
|
+
landed = await cursor.fetchone()
|
|
417
|
+
if landed is None:
|
|
418
|
+
# The conflict was with the effect's *own* uncommitted insert, so the rollback
|
|
419
|
+
# took that away too and there is nothing to read back. Said plainly here,
|
|
420
|
+
# because the alternative is a `NoneType` error from a line that looks like
|
|
421
|
+
# ordinary decoding, and because the cure is on the caller's side: the
|
|
422
|
+
# application's tables are what an effect writes, and the step's own row is
|
|
423
|
+
# the store's to write from what the effect returned.
|
|
424
|
+
raise ValueError(
|
|
425
|
+
f"the effect for {key!r} wrote that step's own checkpoint row, so its transaction "
|
|
426
|
+
f"could not record the step and was rolled back; an effect writes the application's tables"
|
|
427
|
+
)
|
|
428
|
+
return self.codec.decode(cast(str, landed[0]))
|
|
429
|
+
|
|
430
|
+
async def supply(self, workflow: str, key: str, value: object) -> object:
|
|
431
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
432
|
+
await cursor.execute(SUPPLY, {"workflow": workflow, "step": key, "value": self.codec.encode(value)})
|
|
433
|
+
return self.codec.decode(cast(tuple[str], await cursor.fetchone())[0])
|
|
434
|
+
|
|
435
|
+
async def release(self, holder: Pass) -> None:
|
|
436
|
+
async with self.pool.connection() as connection:
|
|
437
|
+
await connection.execute(RELEASE, (holder.workflow, holder.token))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# Take the oldest workflow that is visible and push it a lease into the future, in one
|
|
441
|
+
# statement, so two workers polling at the same instant cannot both take it.
|
|
442
|
+
#
|
|
443
|
+
# `FOR UPDATE SKIP LOCKED` is the whole of the distribution: the row the first worker is
|
|
444
|
+
# updating is locked, and the second does not queue behind it but passes over it to the
|
|
445
|
+
# next visible row. Without `SKIP LOCKED` a pool of workers polling one queue serializes
|
|
446
|
+
# on its head; with it, they fan out. It is also why nothing here needs a consumer group.
|
|
447
|
+
#
|
|
448
|
+
# `AS MATERIALIZED` is what makes `LIMIT 1` mean one row, and it is load-bearing rather
|
|
449
|
+
# than a hint. A `LIMIT` bounds what a sub-select *returns*, not how many times the planner
|
|
450
|
+
# may evaluate it: written as a plain sub-select in the `FROM`, it can land on the inner
|
|
451
|
+
# side of a nested loop and be rescanned per outer row, and a rescanned `SKIP LOCKED` scan
|
|
452
|
+
# does not repeat itself, since it passes over the rows this same statement has already
|
|
453
|
+
# locked. Each rescan would then yield a *different* workflow, and the statement would
|
|
454
|
+
# lease several while `next_ready` reads one row and drops the rest, leaving the others
|
|
455
|
+
# invisible for a full lease with no delivery in anyone's hands.
|
|
456
|
+
#
|
|
457
|
+
# Which plan a version of Postgres picks for which statistics is not a thing this store
|
|
458
|
+
# should have an opinion about, and that is the argument for materializing rather than for
|
|
459
|
+
# trusting the shape: the CTE is evaluated once, before the update, so the count is a
|
|
460
|
+
# property of the statement instead of a property of the plan. It is also the idiom the
|
|
461
|
+
# queue-in-Postgres pattern is usually written with, for this reason.
|
|
462
|
+
#
|
|
463
|
+
# The new `visible_at` is returned because it *is* the receipt, which is the trick
|
|
464
|
+
# `RedisSetScheduler` documents at length: a workflow appears once, so a wakeup arriving
|
|
465
|
+
# mid-pass lands on top of the entry that pass is holding, and finishing has to be
|
|
466
|
+
# conditional on the value being unchanged or it throws the wakeup away.
|
|
467
|
+
#
|
|
468
|
+
# returns the workflow and its new visibility, or no row when nothing is visible yet
|
|
469
|
+
TAKE = """
|
|
470
|
+
WITH due AS MATERIALIZED (
|
|
471
|
+
SELECT workflow FROM workflow_queue
|
|
472
|
+
WHERE namespace = %(namespace)s AND visible_at <= now()
|
|
473
|
+
ORDER BY visible_at
|
|
474
|
+
FOR UPDATE SKIP LOCKED
|
|
475
|
+
LIMIT 1
|
|
476
|
+
)
|
|
477
|
+
UPDATE workflow_queue AS entry
|
|
478
|
+
SET visible_at = now() + %(lease)s
|
|
479
|
+
FROM due
|
|
480
|
+
WHERE entry.namespace = %(namespace)s AND entry.workflow = due.workflow
|
|
481
|
+
RETURNING entry.workflow, entry.visible_at
|
|
482
|
+
"""
|
|
483
|
+
|
|
484
|
+
# Make the workflow visible at `visible_at`, whatever it was waiting for before. A plain
|
|
485
|
+
# upsert rather than a conditional one, including over a pass in flight: landing on top of
|
|
486
|
+
# a running pass's lease is what keeps the wakeup alive, since that pass will now decline
|
|
487
|
+
# to remove the row.
|
|
488
|
+
SCHEDULE = """
|
|
489
|
+
INSERT INTO workflow_queue (namespace, workflow, visible_at)
|
|
490
|
+
VALUES (%(namespace)s, %(workflow)s, %(visible_at)s)
|
|
491
|
+
ON CONFLICT (namespace, workflow) DO UPDATE SET visible_at = EXCLUDED.visible_at
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
# Finish, but only if nothing asked for another pass in the meantime. Anything that did
|
|
495
|
+
# wrote a different `visible_at`, so the equality is the whole check.
|
|
496
|
+
FINISH = "DELETE FROM workflow_queue WHERE namespace = %s AND workflow = %s AND visible_at = %s"
|
|
497
|
+
|
|
498
|
+
# Suspend until a deadline, under the same comparison and for the same reason. A workflow
|
|
499
|
+
# holds one row here, so writing the deadline unconditionally would land on top of a
|
|
500
|
+
# `make_ready` that arrived while the pass was ending and push a confirmation out to a
|
|
501
|
+
# deadline that may be days away. The row anything else wrote carries a different
|
|
502
|
+
# `visible_at`, and this leaves it alone: the sooner wakeup is the one that was wanted,
|
|
503
|
+
# and the deadline is in the checkpoint, so the pass that runs writes it again.
|
|
504
|
+
SUSPEND = """
|
|
505
|
+
UPDATE workflow_queue SET visible_at = %(when)s
|
|
506
|
+
WHERE namespace = %(namespace)s AND workflow = %(workflow)s AND visible_at = %(receipt)s
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@dataclass(frozen=True, slots=True)
|
|
511
|
+
class PostgresScheduler:
|
|
512
|
+
"""
|
|
513
|
+
`Scheduler` as one table, each row scored by when its workflow becomes visible.
|
|
514
|
+
|
|
515
|
+
A drop-in for either Redis queue: the same protocol, the same worker, the same API.
|
|
516
|
+
It is modelled on the sorted-set one rather than on the stream, so queued now is a
|
|
517
|
+
`visible_at` in the past, sleeping is one in the future, and being worked on is one a
|
|
518
|
+
lease ahead, which leaves `wake_due`, `reclaim`, and `prepare`'s queue half with
|
|
519
|
+
nothing to do.
|
|
520
|
+
|
|
521
|
+
What Postgres adds over the sorted set is `SKIP LOCKED`, which is what lets several
|
|
522
|
+
workers poll one queue without serializing on its head, and what a `ZRANGEBYSCORE` in
|
|
523
|
+
a Lua script gets instead by being the only thing running.
|
|
524
|
+
|
|
525
|
+
What it does not add is the blocking read. This polls on `poll`, so an idle worker
|
|
526
|
+
costs a round trip per interval and a submitted order waits up to one interval to be
|
|
527
|
+
picked up. Postgres can close that (`LISTEN`/`NOTIFY` on a dedicated connection, woken
|
|
528
|
+
by a trigger or by the writer) and this does not, which is the honest state of it
|
|
529
|
+
rather than a claim that a table cannot wait.
|
|
530
|
+
|
|
531
|
+
`namespace` separates queues rather than deployments, and it is a column rather than
|
|
532
|
+
part of a table name, so a queue name is data here as a workflow id is.
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
pool: AsyncConnectionPool
|
|
536
|
+
namespace: str = "workflow"
|
|
537
|
+
# How long a taken workflow stays invisible, and so how long after a worker dies
|
|
538
|
+
# before someone else picks its workflow up. `worker.work` reads it and claims the
|
|
539
|
+
# workflow for the same span, which is the whole reason it is one number: a workflow
|
|
540
|
+
# that becomes visible before its claim lapses is taken by a worker that cannot write
|
|
541
|
+
# to it yet. This is the knob for a deployment whose passes take longer than a minute.
|
|
542
|
+
lease: timedelta = LEASE
|
|
543
|
+
poll: timedelta = POLL
|
|
544
|
+
# Only `make_ready` reads it: "visible now" is the one time a caller names, where the
|
|
545
|
+
# lease is measured by the server (in `TAKE`) and a deadline was chosen by the
|
|
546
|
+
# workflow itself. Injected so a test can place a wakeup in a clock it controls.
|
|
547
|
+
now: Callable[[], datetime] = now_utc
|
|
548
|
+
# The poll interval as the number `asyncio.sleep` wants, rendered once rather than per
|
|
549
|
+
# iteration of `next_ready`'s loop, which is the one place here that runs more than
|
|
550
|
+
# once per unit of work. `lease` stays a `timedelta`, since psycopg adapts it directly
|
|
551
|
+
# into the `interval` the statement wants.
|
|
552
|
+
poll_seconds: float = field(init=False, repr=False, compare=False)
|
|
553
|
+
|
|
554
|
+
def __post_init__(self) -> None:
|
|
555
|
+
check_duration("a lease", self.lease)
|
|
556
|
+
check_duration("a poll interval", self.poll)
|
|
557
|
+
object.__setattr__(self, "poll_seconds", self.poll.total_seconds())
|
|
558
|
+
|
|
559
|
+
async def prepare(self) -> None:
|
|
560
|
+
"""
|
|
561
|
+
Create the tables, which every worker does at boot and all but the first find done.
|
|
562
|
+
|
|
563
|
+
It creates the *checkpoint* tables too, because there is one database and one DDL
|
|
564
|
+
for it. That is a little more than this interface is asked for, and it is the right
|
|
565
|
+
place anyway: the worker already calls `prepare` before reading a queue, so a
|
|
566
|
+
deployment gets its schema from the same call whichever queue it runs, and an
|
|
567
|
+
entrypoint that would rather be explicit calls `migrate` itself.
|
|
568
|
+
"""
|
|
569
|
+
await migrate(self.pool)
|
|
570
|
+
|
|
571
|
+
async def make_ready(self, workflow: str) -> None:
|
|
572
|
+
await self.schedule(workflow, self.now())
|
|
573
|
+
|
|
574
|
+
async def wake_at(self, delivery: Delivery, when: datetime) -> None:
|
|
575
|
+
"""
|
|
576
|
+
Suspend the workflow until `when`, unless something asked for a pass meanwhile.
|
|
577
|
+
|
|
578
|
+
The receipt is the visibility this pass took, so anything that rescheduled the
|
|
579
|
+
workflow since (a confirmation, another worker taking over an overrun) wrote a
|
|
580
|
+
different one and this leaves it be. Which is the right answer rather than a
|
|
581
|
+
concession: the deadline lives in the workflow's checkpoint, so the pass that runs
|
|
582
|
+
sooner reaches the same `sleep` and writes it again.
|
|
583
|
+
"""
|
|
584
|
+
async with self.pool.connection() as connection:
|
|
585
|
+
await connection.execute(
|
|
586
|
+
SUSPEND,
|
|
587
|
+
{
|
|
588
|
+
"namespace": self.namespace,
|
|
589
|
+
"workflow": delivery.workflow,
|
|
590
|
+
"receipt": datetime.fromisoformat(delivery.receipt),
|
|
591
|
+
"when": when,
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
async def schedule(self, workflow: str, visible_at: datetime) -> None:
|
|
596
|
+
async with self.pool.connection() as connection:
|
|
597
|
+
await connection.execute(
|
|
598
|
+
SCHEDULE,
|
|
599
|
+
{"namespace": self.namespace, "workflow": workflow, "visible_at": visible_at},
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
async def wake_due(self, now: datetime) -> tuple[str, ...]:
|
|
603
|
+
"""Nothing to do: a workflow whose `visible_at` has passed is already visible."""
|
|
604
|
+
return ()
|
|
605
|
+
|
|
606
|
+
async def next_ready(self, within: timedelta) -> Delivery | None:
|
|
607
|
+
"""
|
|
608
|
+
The next visible workflow, waiting up to `within` for one to appear.
|
|
609
|
+
|
|
610
|
+
Polling, because nothing here is listening. `within` bounds how long a cancelled
|
|
611
|
+
worker sits in this call before it can notice, but unlike a blocking read it is
|
|
612
|
+
spent in round trips rather than in one parked call, which is the cost of the
|
|
613
|
+
design and the reason `poll` is a knob.
|
|
614
|
+
"""
|
|
615
|
+
deadline = monotonic() + within.total_seconds()
|
|
616
|
+
while True:
|
|
617
|
+
async with self.pool.connection() as connection, connection.cursor() as cursor:
|
|
618
|
+
await cursor.execute(TAKE, {"namespace": self.namespace, "lease": self.lease})
|
|
619
|
+
taken = await cursor.fetchone()
|
|
620
|
+
if taken is not None:
|
|
621
|
+
workflow, visible_at = taken
|
|
622
|
+
# The receipt is the visibility this take wrote, rendered so it is a value
|
|
623
|
+
# rather than a place: `done` compares it back and declines to remove a row
|
|
624
|
+
# anything else has since rescheduled.
|
|
625
|
+
return Delivery(workflow=workflow, receipt=cast(datetime, visible_at).isoformat())
|
|
626
|
+
remaining = deadline - monotonic()
|
|
627
|
+
if remaining <= 0:
|
|
628
|
+
return None
|
|
629
|
+
await asyncio.sleep(min(self.poll_seconds, remaining))
|
|
630
|
+
|
|
631
|
+
async def reclaim(self, idle: timedelta) -> Delivery | None:
|
|
632
|
+
"""Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
|
|
633
|
+
return None
|
|
634
|
+
|
|
635
|
+
async def done(self, delivery: Delivery) -> None:
|
|
636
|
+
"""
|
|
637
|
+
Drop the workflow, unless something asked for another pass while this one ran.
|
|
638
|
+
|
|
639
|
+
The receipt is the visibility this pass took, so anything that rescheduled the
|
|
640
|
+
workflow meanwhile (a confirmation, this pass's own `wake_at`, another worker
|
|
641
|
+
taking over an overrun) wrote a different one and this leaves it alone. That is why
|
|
642
|
+
a worker may call `wake_at` and then `done` in that order without the second
|
|
643
|
+
undoing the first.
|
|
644
|
+
"""
|
|
645
|
+
async with self.pool.connection() as connection:
|
|
646
|
+
await connection.execute(
|
|
647
|
+
FINISH,
|
|
648
|
+
(self.namespace, delivery.workflow, datetime.fromisoformat(delivery.receipt)),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@dataclass(frozen=True, slots=True)
|
|
653
|
+
class PostgresDurable:
|
|
654
|
+
"""
|
|
655
|
+
A `Durable` whose two stores are one database, so `arrive` is a single commit.
|
|
656
|
+
|
|
657
|
+
This is the row `SplitDurable` cannot fill in. Recording the value a workflow is
|
|
658
|
+
waiting on and making the workflow runnable are two writes with a crash window
|
|
659
|
+
between them everywhere else; here they are two statements in one transaction, so the
|
|
660
|
+
window does not exist. That is the same capability `transact` offers a step, arriving
|
|
661
|
+
at the interface above rather than inside a pass, and it is available for the same reason:
|
|
662
|
+
both things live in one datastore.
|
|
663
|
+
|
|
664
|
+
Which is why the two stores MUST share a pool, checked at construction rather than
|
|
665
|
+
documented. It is the exact question `LuaEffect` asks with its hash tag, and it does
|
|
666
|
+
not stop being asked because SQL hides it: a checkpoint and a queue in two Postgres
|
|
667
|
+
databases are two datastores, and a transaction across them is a distributed
|
|
668
|
+
transaction whatever the connection string suggests. Sharded Postgres asks it again
|
|
669
|
+
at the next level down, where the answer is that both tables must be distributed by
|
|
670
|
+
the workflow id and co-located, or the "one commit" here becomes a two-phase commit
|
|
671
|
+
across nodes.
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
checkpointer: PostgresCheckpointer
|
|
675
|
+
scheduler: PostgresScheduler
|
|
676
|
+
|
|
677
|
+
def __post_init__(self) -> None:
|
|
678
|
+
if self.checkpointer.pool is not self.scheduler.pool:
|
|
679
|
+
raise ValueError("a PostgresDurable's two stores must share one pool, or `arrive` is not one commit")
|
|
680
|
+
|
|
681
|
+
async def arrive(self, workflow: str, key: str, value: object) -> object:
|
|
682
|
+
"""
|
|
683
|
+
Record the value and make the workflow ready, together or not at all.
|
|
684
|
+
|
|
685
|
+
The order within the transaction does not matter, which is the point: a commit
|
|
686
|
+
has no halfway. What does matter is that both statements go through the *same*
|
|
687
|
+
cursor, since a second connection would be a second transaction wearing the same
|
|
688
|
+
method's name.
|
|
689
|
+
"""
|
|
690
|
+
codec = self.checkpointer.codec
|
|
691
|
+
async with self.checkpointer.pool.connection() as connection, connection.cursor() as cursor:
|
|
692
|
+
await cursor.execute(SUPPLY, {"workflow": workflow, "step": key, "value": codec.encode(value)})
|
|
693
|
+
stored = cast(tuple[str], await cursor.fetchone())
|
|
694
|
+
await cursor.execute(
|
|
695
|
+
SCHEDULE,
|
|
696
|
+
{"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": self.scheduler.now()},
|
|
697
|
+
)
|
|
698
|
+
return codec.decode(stored[0])
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.11,<0.12"]
|
|
3
|
-
build-backend = "uv_build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "without-durability-postgres"
|
|
7
|
-
version = "0.0.0"
|
|
8
|
-
description = "Placeholder reserving the PyPI project name; the first real release supersedes it."
|
|
9
|
-
requires-python = ">=3.9"
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.11,<0.12"]
|
|
3
|
-
build-backend = "uv_build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "without-durability-postgres"
|
|
7
|
-
version = "0.0.0"
|
|
8
|
-
description = "Placeholder reserving the PyPI project name; the first real release supersedes it."
|
|
9
|
-
requires-python = ">=3.9"
|