without-durability-sqlite 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_sqlite-0.0.2/PKG-INFO +59 -0
- without_durability_sqlite-0.0.2/README.md +39 -0
- without_durability_sqlite-0.0.2/pyproject.toml +30 -0
- without_durability_sqlite-0.0.2/pyproject.toml.orig +31 -0
- without_durability_sqlite-0.0.2/src/without_durability_sqlite/__init__.py +19 -0
- without_durability_sqlite-0.0.2/src/without_durability_sqlite/store.py +612 -0
- without_durability_sqlite-0.0.0/PKG-INFO +0 -5
- without_durability_sqlite-0.0.0/pyproject.toml +0 -9
- without_durability_sqlite-0.0.0/pyproject.toml.orig +0 -9
- /without_durability_sqlite-0.0.0/src/without_durability_sqlite/__init__.py → /without_durability_sqlite-0.0.2/src/without_durability_sqlite/py.typed +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: without-durability-sqlite
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A without-durability checkpoint store and queue backed by one SQLite file, with no server and no third-party driver.
|
|
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-Python: >=3.14
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# without-durability-sqlite
|
|
22
|
+
|
|
23
|
+
[`without-durability`](https://pypi.org/project/without-durability/)'s two
|
|
24
|
+
interfaces over one SQLite file. No server, and no third-party dependency: the driver is in
|
|
25
|
+
the standard library.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from without_durability_sqlite import SqliteCheckpointer, SqliteDurable, SqliteScheduler, connect, migrate
|
|
29
|
+
|
|
30
|
+
database = connect("workflows.db")
|
|
31
|
+
await migrate(database)
|
|
32
|
+
durable = SqliteDurable(SqliteCheckpointer(database), SqliteScheduler(database))
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
It is the smallest thing that still meets every requirement the interface states,
|
|
36
|
+
which is the clearest way to say what the interface is for: a durable workflow does not need
|
|
37
|
+
a cluster, a database server, or a dependency.
|
|
38
|
+
|
|
39
|
+
Two questions the other stores have to answer carefully settle themselves here.
|
|
40
|
+
There is one writer at a time by construction, so `BEGIN IMMEDIATE` takes the
|
|
41
|
+
write lock for the whole transaction and the fence check and the write it guards
|
|
42
|
+
cannot be interleaved: Postgres needs `FOR UPDATE` on the claim row to get that
|
|
43
|
+
and Redis needs a Lua script, while here the transaction *is* the exclusion. And
|
|
44
|
+
there is nothing to co-locate, because the datastore is a file, so `transact` and
|
|
45
|
+
`arrive` reach every table an application keeps in it. That last one is the same
|
|
46
|
+
guarantee DBOS gets from Postgres, for an application that never needed Postgres.
|
|
47
|
+
|
|
48
|
+
What it costs is the shape of the whole thing: one machine. Every process sharing
|
|
49
|
+
this store shares a filesystem, so the exclusion holds across the processes on one
|
|
50
|
+
box and not across a fleet. That is the deployment this is for rather than a
|
|
51
|
+
defect to apologise for: a CLI that resumes, a desktop app, an agent on a laptop,
|
|
52
|
+
a single node that would rather not run Postgres to remember what it was doing.
|
|
53
|
+
|
|
54
|
+
See the
|
|
55
|
+
[`without-durability-sqlite` guide](https://without.help/without-durability-sqlite/)
|
|
56
|
+
(with the [API reference](https://without.help/without-durability-sqlite/reference/))
|
|
57
|
+
for the statements, why `connect` pays the fsync that the usual WAL advice trades
|
|
58
|
+
away, why an effect here is a synchronous callback where the Postgres store's is
|
|
59
|
+
`async`, and how the blocking driver is kept off the event loop.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# without-durability-sqlite
|
|
2
|
+
|
|
3
|
+
[`without-durability`](https://pypi.org/project/without-durability/)'s two
|
|
4
|
+
interfaces over one SQLite file. No server, and no third-party dependency: the driver is in
|
|
5
|
+
the standard library.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from without_durability_sqlite import SqliteCheckpointer, SqliteDurable, SqliteScheduler, connect, migrate
|
|
9
|
+
|
|
10
|
+
database = connect("workflows.db")
|
|
11
|
+
await migrate(database)
|
|
12
|
+
durable = SqliteDurable(SqliteCheckpointer(database), SqliteScheduler(database))
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
It is the smallest thing that still meets every requirement the interface states,
|
|
16
|
+
which is the clearest way to say what the interface is for: a durable workflow does not need
|
|
17
|
+
a cluster, a database server, or a dependency.
|
|
18
|
+
|
|
19
|
+
Two questions the other stores have to answer carefully settle themselves here.
|
|
20
|
+
There is one writer at a time by construction, so `BEGIN IMMEDIATE` takes the
|
|
21
|
+
write lock for the whole transaction and the fence check and the write it guards
|
|
22
|
+
cannot be interleaved: Postgres needs `FOR UPDATE` on the claim row to get that
|
|
23
|
+
and Redis needs a Lua script, while here the transaction *is* the exclusion. And
|
|
24
|
+
there is nothing to co-locate, because the datastore is a file, so `transact` and
|
|
25
|
+
`arrive` reach every table an application keeps in it. That last one is the same
|
|
26
|
+
guarantee DBOS gets from Postgres, for an application that never needed Postgres.
|
|
27
|
+
|
|
28
|
+
What it costs is the shape of the whole thing: one machine. Every process sharing
|
|
29
|
+
this store shares a filesystem, so the exclusion holds across the processes on one
|
|
30
|
+
box and not across a fleet. That is the deployment this is for rather than a
|
|
31
|
+
defect to apologise for: a CLI that resumes, a desktop app, an agent on a laptop,
|
|
32
|
+
a single node that would rather not run Postgres to remember what it was doing.
|
|
33
|
+
|
|
34
|
+
See the
|
|
35
|
+
[`without-durability-sqlite` guide](https://without.help/without-durability-sqlite/)
|
|
36
|
+
(with the [API reference](https://without.help/without-durability-sqlite/reference/))
|
|
37
|
+
for the statements, why `connect` pays the fsync that the usual WAL advice trades
|
|
38
|
+
away, why an effect here is a synchronous callback where the Postgres store's is
|
|
39
|
+
`async`, and how the blocking driver is kept off the event loop.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.29,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-durability-sqlite"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "A without-durability checkpoint store and queue backed by one SQLite file, with no server and no third-party driver."
|
|
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 = ["without-durability==0.0.2"]
|
|
24
|
+
|
|
25
|
+
[[project.authors]]
|
|
26
|
+
name = "Josh Karpel"
|
|
27
|
+
email = "josh.karpel@gmail.com"
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources.without-durability]
|
|
30
|
+
workspace = true
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.29,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-durability-sqlite"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
description = "A without-durability checkpoint store and queue backed by one SQLite file, with no server and no third-party driver."
|
|
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
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.uv.sources]
|
|
31
|
+
without-durability = { workspace = true }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from without_durability_sqlite.store import SCHEMA
|
|
2
|
+
from without_durability_sqlite.store import Database
|
|
3
|
+
from without_durability_sqlite.store import SqliteCheckpointer
|
|
4
|
+
from without_durability_sqlite.store import SqliteDurable
|
|
5
|
+
from without_durability_sqlite.store import SqliteEffect
|
|
6
|
+
from without_durability_sqlite.store import SqliteScheduler
|
|
7
|
+
from without_durability_sqlite.store import connect
|
|
8
|
+
from without_durability_sqlite.store import migrate
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SCHEMA",
|
|
12
|
+
"Database",
|
|
13
|
+
"SqliteCheckpointer",
|
|
14
|
+
"SqliteDurable",
|
|
15
|
+
"SqliteEffect",
|
|
16
|
+
"SqliteScheduler",
|
|
17
|
+
"connect",
|
|
18
|
+
"migrate",
|
|
19
|
+
]
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
# The same three tables again, in one file on one machine, with no server and no
|
|
2
|
+
# third-party driver. It is the smallest thing that still meets every requirement in
|
|
3
|
+
# `without_durability.interfaces`, and putting it beside the Redis and Postgres stores is the
|
|
4
|
+
# clearest statement of what the interface is for: a durable workflow does not need a cluster,
|
|
5
|
+
# a database server, or a dependency.
|
|
6
|
+
#
|
|
7
|
+
# What SQLite settles that the others have to arrange:
|
|
8
|
+
#
|
|
9
|
+
# - There is one writer at a time, by construction. `BEGIN IMMEDIATE` takes the write
|
|
10
|
+
# lock for the whole transaction, so the fence check and the write it guards cannot
|
|
11
|
+
# be interleaved with anything. Postgres needs `FOR UPDATE` on the claim row to get
|
|
12
|
+
# that, because there readers and writers run concurrently and a statement's snapshot
|
|
13
|
+
# can be stale; Redis needs a Lua script. Here the transaction *is* the exclusion.
|
|
14
|
+
# - There is nothing to co-locate. `transact` and `arrive` reach the whole datastore
|
|
15
|
+
# because the datastore is a file, so the question the other two stores have to keep
|
|
16
|
+
# asking (are these two writes in one local commit?) has one answer and it is yes.
|
|
17
|
+
#
|
|
18
|
+
# What it costs is the shape of the whole thing: one machine. Every process sharing this
|
|
19
|
+
# store shares a filesystem, which means the exclusion holds across the processes on one
|
|
20
|
+
# box and not across a fleet. That is not a defect to apologise for, it is the deployment
|
|
21
|
+
# this store is for: a CLI that resumes, a desktop app, an agent on a laptop, a single
|
|
22
|
+
# node that would rather not run Postgres to remember what it was doing.
|
|
23
|
+
#
|
|
24
|
+
# `sqlite3` is a blocking API, so every call here hops to a thread, and that hop is what
|
|
25
|
+
# creates the concurrency this store has to answer for. A single-threaded event loop does
|
|
26
|
+
# not serialize these: `asyncio.to_thread` exists to get the work *off* that thread, so
|
|
27
|
+
# twenty passes are twenty pool workers inside one connection at once. One `asyncio.Lock`
|
|
28
|
+
# puts them back in a queue.
|
|
29
|
+
#
|
|
30
|
+
# What that lock is for is worth stating exactly, because SQLite's own answer sounds like
|
|
31
|
+
# it covers the case and does not. The library is built serialized here (`THREADSAFE=1`,
|
|
32
|
+
# `sqlite3.threadsafety == 3`), so concurrent use of one connection is already safe from
|
|
33
|
+
# corruption; what it promises is that the calls behave "as if they had all been made in
|
|
34
|
+
# the same order from a single thread", which is *linearization, not isolation*. A
|
|
35
|
+
# transaction is connection state, so a second caller landing mid-`BEGIN IMMEDIATE` joins
|
|
36
|
+
# that transaction rather than waiting for it: its write succeeds, reads back, and then
|
|
37
|
+
# disappears when the other caller rolls back. That is the failure the lock removes, and
|
|
38
|
+
# no threading mode removes it.
|
|
39
|
+
#
|
|
40
|
+
# What the lock costs is the other half of WAL. WAL exists so one writer runs alongside
|
|
41
|
+
# many readers, and one connection gives that up: `load` and `next_ready` queue behind
|
|
42
|
+
# whatever commit is in flight, `synchronous=FULL` fsync included. Buying it back means
|
|
43
|
+
# more connections (a reader pool, or one per thread) rather than a different lock, which
|
|
44
|
+
# is a bigger store than this one.
|
|
45
|
+
#
|
|
46
|
+
# Requires SQLite 3.42 or newer (2023-05-16), which is where the `subsec` modifier
|
|
47
|
+
# arrives. Every clock read below is `unixepoch('now', 'subsec')`, and without `subsec`
|
|
48
|
+
# that is whole seconds: a lease and a visibility would round to the same second, so two
|
|
49
|
+
# workers polling within one second of each other could both find a row visible. It is
|
|
50
|
+
# not a floor `requires-python` can enforce, because Python bundles a recent SQLite on
|
|
51
|
+
# Windows and macOS but on Linux `sqlite3` links whatever `libsqlite3` the distribution
|
|
52
|
+
# ships. `sqlite3.sqlite_version` is what a deployment should check.
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import asyncio
|
|
57
|
+
import sqlite3
|
|
58
|
+
from collections.abc import Callable
|
|
59
|
+
from contextlib import closing
|
|
60
|
+
from dataclasses import dataclass
|
|
61
|
+
from dataclasses import field
|
|
62
|
+
from datetime import datetime
|
|
63
|
+
from datetime import timedelta
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
from time import monotonic
|
|
66
|
+
from typing import cast
|
|
67
|
+
|
|
68
|
+
from without_durability.codec import JSON
|
|
69
|
+
from without_durability.codec import CheckpointCodec
|
|
70
|
+
from without_durability.interfaces import LEASE
|
|
71
|
+
from without_durability.interfaces import Delivery
|
|
72
|
+
from without_durability.interfaces import Fenced
|
|
73
|
+
from without_durability.interfaces import Pass
|
|
74
|
+
from without_durability.interfaces import Recorded
|
|
75
|
+
from without_durability.interfaces import check_duration
|
|
76
|
+
from without_durability.stepwise import now_utc
|
|
77
|
+
|
|
78
|
+
# How often a worker with nothing to do asks again, which is the price of having no
|
|
79
|
+
# blocking read. The *lease* is not restated here: it is `interfaces.LEASE`, because unlike the
|
|
80
|
+
# poll interval it has to agree with something outside this store (the checkpoint claim
|
|
81
|
+
# the worker takes for exactly as long).
|
|
82
|
+
POLL = timedelta(milliseconds=50)
|
|
83
|
+
|
|
84
|
+
# `value` is TEXT rather than a richer type, which is the same shape the Redis store's
|
|
85
|
+
# hash field has and leaves the same question open: what goes *in* the text is the
|
|
86
|
+
# store's injected `CheckpointCodec`, defaulting to JSON because that is what makes a
|
|
87
|
+
# checkpoint readable by anything that can open the file. `WITHOUT ROWID` because every
|
|
88
|
+
# one of these tables is addressed by its primary key and never by a rowid, so the extra
|
|
89
|
+
# indirection would be pure overhead.
|
|
90
|
+
#
|
|
91
|
+
# `NOT NULL` on `value` keeps "no row" and "a row holding JSON null" distinguishable, so
|
|
92
|
+
# a step that legitimately records `None` is not read back as a step that never ran.
|
|
93
|
+
SCHEMA = """
|
|
94
|
+
CREATE TABLE IF NOT EXISTS workflow_checkpoint (
|
|
95
|
+
workflow TEXT NOT NULL,
|
|
96
|
+
step TEXT NOT NULL,
|
|
97
|
+
value TEXT NOT NULL,
|
|
98
|
+
PRIMARY KEY (workflow, step)
|
|
99
|
+
) WITHOUT ROWID;
|
|
100
|
+
|
|
101
|
+
CREATE TABLE IF NOT EXISTS workflow_claim (
|
|
102
|
+
workflow TEXT PRIMARY KEY,
|
|
103
|
+
token INTEGER NOT NULL,
|
|
104
|
+
held_until REAL NOT NULL
|
|
105
|
+
) WITHOUT ROWID;
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS workflow_queue (
|
|
108
|
+
namespace TEXT NOT NULL,
|
|
109
|
+
workflow TEXT NOT NULL,
|
|
110
|
+
visible_at REAL NOT NULL,
|
|
111
|
+
PRIMARY KEY (namespace, workflow)
|
|
112
|
+
) WITHOUT ROWID;
|
|
113
|
+
|
|
114
|
+
CREATE INDEX IF NOT EXISTS workflow_queue_visible_at ON workflow_queue (namespace, visible_at);
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# Take the workflow if nobody holds it, and stamp the taking with the next number up.
|
|
118
|
+
# Identical in shape to the Postgres statement, down to the clause that does the work:
|
|
119
|
+
# the `WHERE` on `DO UPDATE` is the "is it free" check, and a conflicting row whose lease
|
|
120
|
+
# has not elapsed fails it, so nothing is written and `RETURNING` yields no row.
|
|
121
|
+
#
|
|
122
|
+
# The clock is the database's, which here is a formality rather than a guarantee. Redis
|
|
123
|
+
# and Postgres read their server's clock because the claimant is a different machine and
|
|
124
|
+
# a lease compared against the caller's own clock is only as good as the agreement
|
|
125
|
+
# between the two. SQLite *is* the caller's machine, so that argument does not apply;
|
|
126
|
+
# keeping the clock in SQL anyway costs nothing and keeps the three stores reading alike.
|
|
127
|
+
CLAIM = """
|
|
128
|
+
INSERT INTO workflow_claim (workflow, token, held_until)
|
|
129
|
+
VALUES (:workflow, 1, unixepoch('now', 'subsec') + :lease)
|
|
130
|
+
ON CONFLICT (workflow) DO UPDATE
|
|
131
|
+
SET token = workflow_claim.token + 1, held_until = unixepoch('now', 'subsec') + :lease
|
|
132
|
+
WHERE workflow_claim.held_until <= unixepoch('now', 'subsec')
|
|
133
|
+
RETURNING token
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
# The fenced, conditional write, as one statement. The Postgres version wraps its fence
|
|
137
|
+
# read in a `FOR UPDATE` CTE so a claim committing mid-statement cannot go unseen; here
|
|
138
|
+
# the statement is its own transaction and SQLite admits one writer, so selecting the
|
|
139
|
+
# claim row inline is already serialized against every other write.
|
|
140
|
+
#
|
|
141
|
+
# `DO UPDATE SET value = the value already there` is a write that changes nothing and
|
|
142
|
+
# therefore returns the row that was already stored, which is how a caller that lost the
|
|
143
|
+
# race learns the winner's value instead of carrying on with its own.
|
|
144
|
+
#
|
|
145
|
+
# The second returned column is who won, which the caller cannot work out afterwards (see
|
|
146
|
+
# `Recorded`). Comparing the stored *text* against the text this call offered answers it
|
|
147
|
+
# in the statement, where both are in hand, and two passes that ran the same effect and
|
|
148
|
+
# encoded it identically both count as having won: there is nothing to disagree about.
|
|
149
|
+
RECORD = """
|
|
150
|
+
INSERT INTO workflow_checkpoint (workflow, step, value)
|
|
151
|
+
SELECT :workflow, :step, :value FROM workflow_claim
|
|
152
|
+
WHERE workflow = :workflow AND token <= :token
|
|
153
|
+
ON CONFLICT (workflow, step) DO UPDATE SET value = workflow_checkpoint.value
|
|
154
|
+
RETURNING value, value = :value
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
# The same conditional write without the fence, for a value that comes from outside any
|
|
158
|
+
# pass. Deliberately not gated on a claim: an approval must not fail because a worker
|
|
159
|
+
# happens to be mid-pass, and first-writer-wins is the whole guarantee it needs.
|
|
160
|
+
SUPPLY = """
|
|
161
|
+
INSERT INTO workflow_checkpoint (workflow, step, value)
|
|
162
|
+
VALUES (:workflow, :step, :value)
|
|
163
|
+
ON CONFLICT (workflow, step) DO UPDATE SET value = workflow_checkpoint.value
|
|
164
|
+
RETURNING value
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
FENCE = "SELECT token FROM workflow_claim WHERE workflow = ?"
|
|
168
|
+
ALREADY = "SELECT value FROM workflow_checkpoint WHERE workflow = ? AND step = ?"
|
|
169
|
+
WRITE = "INSERT INTO workflow_checkpoint (workflow, step, value) VALUES (?, ?, ?)"
|
|
170
|
+
LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ?"
|
|
171
|
+
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
172
|
+
# number up and a pass that comes back from the dead still loses.
|
|
173
|
+
RELEASE = "UPDATE workflow_claim SET held_until = unixepoch('now', 'subsec') WHERE workflow = ? AND token = ?"
|
|
174
|
+
|
|
175
|
+
# Take the oldest visible workflow and push it a lease into the future. There is no
|
|
176
|
+
# `SKIP LOCKED` here and none is wanted: it exists so one poller does not queue behind
|
|
177
|
+
# another's row lock, and SQLite has no concurrent writers to step over.
|
|
178
|
+
TAKE = """
|
|
179
|
+
UPDATE workflow_queue SET visible_at = unixepoch('now', 'subsec') + :lease
|
|
180
|
+
WHERE (namespace, workflow) = (
|
|
181
|
+
SELECT namespace, workflow FROM workflow_queue
|
|
182
|
+
WHERE namespace = :namespace AND visible_at <= unixepoch('now', 'subsec')
|
|
183
|
+
ORDER BY visible_at LIMIT 1
|
|
184
|
+
)
|
|
185
|
+
RETURNING workflow, visible_at
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
# Make the workflow visible at `visible_at`, whatever it was waiting for before. A plain
|
|
189
|
+
# upsert rather than a conditional one, including over a pass in flight: landing on top
|
|
190
|
+
# of a running pass's lease is what keeps the wakeup alive, since that pass will then
|
|
191
|
+
# decline to remove the row.
|
|
192
|
+
SCHEDULE = """
|
|
193
|
+
INSERT INTO workflow_queue (namespace, workflow, visible_at)
|
|
194
|
+
VALUES (:namespace, :workflow, :visible_at)
|
|
195
|
+
ON CONFLICT (namespace, workflow) DO UPDATE SET visible_at = excluded.visible_at
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
# Finish, but only if nothing asked for another pass meanwhile. Anything that did wrote a
|
|
199
|
+
# different `visible_at`, so the equality is the whole check.
|
|
200
|
+
FINISH = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ? AND visible_at = ?"
|
|
201
|
+
|
|
202
|
+
# Suspend until a deadline, under the same comparison and for the same reason. A workflow
|
|
203
|
+
# holds one row here, so writing the deadline unconditionally would land on top of a
|
|
204
|
+
# `make_ready` that arrived while the pass was ending and push a confirmation out to a
|
|
205
|
+
# deadline that may be days away. The deadline is in the checkpoint either way, so the
|
|
206
|
+
# pass that runs sooner writes it again.
|
|
207
|
+
SUSPEND = "UPDATE workflow_queue SET visible_at = ? WHERE namespace = ? AND workflow = ? AND visible_at = ?"
|
|
208
|
+
|
|
209
|
+
# What an effect is for a store whose datastore is a SQLite file: a callback handed a
|
|
210
|
+
# cursor already inside `transact`'s transaction.
|
|
211
|
+
#
|
|
212
|
+
# It is *not* async, and that is the difference from the Postgres store's rather than an
|
|
213
|
+
# oversight. The whole transaction runs on one worker thread, so an effect is ordinary
|
|
214
|
+
# blocking code there and awaiting inside it would be both impossible and pointless.
|
|
215
|
+
# Whatever it returns is recorded as the step's value, so it MUST be something the store's
|
|
216
|
+
# codec encodes, and it MUST confine itself to the cursor it is handed: opening another
|
|
217
|
+
# connection puts the work outside the transaction and gives back exactly the at-least-once
|
|
218
|
+
# gap `transact` closes. Unlike Redis's `LuaEffect` it returns an ordinary Python value
|
|
219
|
+
# rather than an encoding, because it runs in this process where the codec is.
|
|
220
|
+
type SqliteEffect = Callable[[sqlite3.Cursor], object]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class Database:
|
|
225
|
+
"""
|
|
226
|
+
One SQLite connection and the lock that keeps one caller in it at a time.
|
|
227
|
+
|
|
228
|
+
The analogue of the Postgres store's connection pool, and the opposite shape for the
|
|
229
|
+
opposite reason: a pool exists so several statements run at once, and this exists so
|
|
230
|
+
they do not. Not because a connection would corrupt (SQLite is built serialized here,
|
|
231
|
+
so it would not), but because a transaction belongs to the *connection*: without this,
|
|
232
|
+
a caller arriving mid-`BEGIN IMMEDIATE` writes into somebody else's transaction and
|
|
233
|
+
loses its write to that transaction's rollback. See the note at the top of this
|
|
234
|
+
module for why the event loop's single thread does not already prevent that.
|
|
235
|
+
|
|
236
|
+
Build it with `connect`, which applies the pragmas that make this durable rather than
|
|
237
|
+
merely persistent. Share one between the checkpoint store and the queue: that is what
|
|
238
|
+
makes `SqliteDurable.arrive` a single commit, and it is checked rather than assumed.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
connection: sqlite3.Connection
|
|
242
|
+
guard: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False, compare=False)
|
|
243
|
+
|
|
244
|
+
async def run[T](self, work: Callable[[sqlite3.Connection], T]) -> T:
|
|
245
|
+
"""
|
|
246
|
+
Do `work` against the connection, on a thread, with nobody else inside it.
|
|
247
|
+
|
|
248
|
+
Cancellation is where "nobody else" has to be arranged rather than assumed, and
|
|
249
|
+
it is the reason this is not simply `async with self.guard`. A thread is not
|
|
250
|
+
cancellable: cancelling the caller unwinds this coroutine at once while the
|
|
251
|
+
thread runs on, so releasing the guard on the way out would hand the connection
|
|
252
|
+
to the next caller while the last one is still inside it. That is not a
|
|
253
|
+
theoretical race. The statement in flight may be a `BEGIN IMMEDIATE`
|
|
254
|
+
transaction, and a write that lands in somebody else's open transaction is
|
|
255
|
+
committed or rolled back with it: `record` returns, a read sees the row, and the
|
|
256
|
+
rollback takes it away again, which is precisely the guarantee this store
|
|
257
|
+
exists to make.
|
|
258
|
+
|
|
259
|
+
So the guard is released by the *thread finishing* rather than by this coroutine
|
|
260
|
+
returning. The work is a task, the caller awaits a shield of it (so cancelling
|
|
261
|
+
the caller leaves the task alone), and a done-callback lets go of the connection
|
|
262
|
+
when it is genuinely free. A cancelled caller still unwinds immediately; what it
|
|
263
|
+
no longer does is take the connection with it.
|
|
264
|
+
|
|
265
|
+
What the shield adds beyond that is the reporting. A statement that fails after
|
|
266
|
+
its caller has gone has nobody left to raise to, and `shield` hands it to the
|
|
267
|
+
loop's exception handler rather than dropping it, so a write that failed on the
|
|
268
|
+
way out of a process is in the log instead of nowhere.
|
|
269
|
+
"""
|
|
270
|
+
await self.guard.acquire()
|
|
271
|
+
running = asyncio.ensure_future(asyncio.to_thread(work, self.connection))
|
|
272
|
+
running.add_done_callback(lambda _finished: self.guard.release())
|
|
273
|
+
return await asyncio.shield(running)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def connect(path: Path | str, *, timeout: timedelta = timedelta(seconds=5)) -> Database:
|
|
277
|
+
"""
|
|
278
|
+
Open the database this store runs on, configured for durability rather than speed.
|
|
279
|
+
|
|
280
|
+
- `journal_mode=WAL` so a reader does not block the writer, which is what lets a
|
|
281
|
+
status query run while a pass is mid-transaction.
|
|
282
|
+
- `synchronous=FULL` because this store's entire claim is that `record` returning
|
|
283
|
+
means the value survives. `NORMAL` is the usual advice under WAL and it trades
|
|
284
|
+
exactly that away: a commit can be lost on power loss or an OS crash. Everything
|
|
285
|
+
`run_durably` reasons about assumes the commit held, so this pays the fsync.
|
|
286
|
+
- `busy_timeout` so a second process finding the write lock taken waits for it rather
|
|
287
|
+
than failing immediately, which is the ordinary case when two processes share the
|
|
288
|
+
file.
|
|
289
|
+
|
|
290
|
+
`autocommit=True` leaves transaction control here rather than in the driver: every
|
|
291
|
+
statement below is either atomic on its own or wrapped in an explicit
|
|
292
|
+
`BEGIN IMMEDIATE`, and nothing is left to a hidden implicit transaction.
|
|
293
|
+
"""
|
|
294
|
+
connection = sqlite3.connect(path, autocommit=True, check_same_thread=False)
|
|
295
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
296
|
+
connection.execute("PRAGMA synchronous = FULL")
|
|
297
|
+
connection.execute(f"PRAGMA busy_timeout = {int(timeout.total_seconds() * 1000)}")
|
|
298
|
+
return Database(connection=connection)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def transacted[T](connection: sqlite3.Connection, work: Callable[[sqlite3.Cursor], T]) -> T:
|
|
302
|
+
"""
|
|
303
|
+
Run `work` between `BEGIN IMMEDIATE` and `COMMIT`, rolling back if it raises.
|
|
304
|
+
|
|
305
|
+
`IMMEDIATE` rather than the default deferred begin, and the difference is the whole
|
|
306
|
+
of the exclusion: a deferred transaction takes the write lock at its first write, so
|
|
307
|
+
a fence *read* before it would not be protected and could be overtaken. Taking the
|
|
308
|
+
lock up front makes the read and the write it guards one step.
|
|
309
|
+
|
|
310
|
+
The commit is inside the `try` because committing is one of the things that can fail:
|
|
311
|
+
a deferred constraint is checked there, so a `COMMIT` can raise with the transaction
|
|
312
|
+
still open. Left outside, nothing rolls that back, and the transaction stays open on a
|
|
313
|
+
connection every later caller shares: their writes join it, return saying they are
|
|
314
|
+
durable, and are lost when it is finally discarded, while the write lock is held
|
|
315
|
+
against every other process on the machine for as long as this one lives. That is
|
|
316
|
+
precisely the failure `Database.run`'s guard exists to prevent, arriving by the one
|
|
317
|
+
door the guard does not cover.
|
|
318
|
+
|
|
319
|
+
The rollback is conditional for the mirror-image reason: SQLite rolls back by itself
|
|
320
|
+
on a full disk, an I/O error, an interrupt, or being out of memory, so an
|
|
321
|
+
unconditional `ROLLBACK` raises "cannot rollback - no transaction is active" *over*
|
|
322
|
+
the error that caused it, and the caller is told the wrong thing about its own
|
|
323
|
+
failure. Asking the connection is how to tell which of the two happened.
|
|
324
|
+
"""
|
|
325
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
326
|
+
try:
|
|
327
|
+
with closing(connection.cursor()) as cursor:
|
|
328
|
+
done = work(cursor)
|
|
329
|
+
connection.execute("COMMIT")
|
|
330
|
+
except BaseException:
|
|
331
|
+
if connection.in_transaction:
|
|
332
|
+
connection.execute("ROLLBACK")
|
|
333
|
+
raise
|
|
334
|
+
return done
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def migrate(database: Database) -> None:
|
|
338
|
+
"""
|
|
339
|
+
Create the three tables, from every process, as often as it likes.
|
|
340
|
+
|
|
341
|
+
No advisory lock and no race to guard against, unlike the Postgres migration: SQLite
|
|
342
|
+
runs the whole script in one exclusive transaction, so a second process either waits
|
|
343
|
+
for it or finds the tables already there.
|
|
344
|
+
|
|
345
|
+
Schema migration as a whole is not what this is. There is no versioning and no path
|
|
346
|
+
from one shape of these tables to another; `user_version` is where SQLite keeps that,
|
|
347
|
+
and a deployment that needs it should use it.
|
|
348
|
+
"""
|
|
349
|
+
await database.run(lambda connection: connection.executescript(SCHEMA))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(frozen=True, slots=True)
|
|
353
|
+
class SqliteCheckpointer:
|
|
354
|
+
"""
|
|
355
|
+
A workflow's completed steps as rows in one file, and its claim as a row beside them.
|
|
356
|
+
|
|
357
|
+
The `Checkpointer` implementation for a deployment that is one machine, and the one
|
|
358
|
+
that needs nothing installed. It meets the same requirements as the others by the
|
|
359
|
+
simplest route any of them take: SQLite admits one writer, so a single statement or a
|
|
360
|
+
single `BEGIN IMMEDIATE` transaction is already all the exclusion this needs.
|
|
361
|
+
|
|
362
|
+
`SqliteEffect` is a callback over the open transaction's cursor, so a step whose
|
|
363
|
+
effect is a write to *this* file happens exactly once. Since the file is the whole
|
|
364
|
+
datastore, that covers every table an application on this machine keeps here, which
|
|
365
|
+
is a broader reach than it sounds: it is the same guarantee DBOS gets from Postgres,
|
|
366
|
+
for an application that never needed Postgres.
|
|
367
|
+
|
|
368
|
+
A workflow id carries no constraints at all: it is bound as a query parameter, never
|
|
369
|
+
parsed as key structure. Nothing here derives one id from another either, so an
|
|
370
|
+
application is free to name a workflow's sibling (a saga's rollback, say) however it
|
|
371
|
+
likes out of its own namespace.
|
|
372
|
+
|
|
373
|
+
`codec` is how a step's result becomes the `TEXT` in a row and comes back, defaulting
|
|
374
|
+
to the stdlib's JSON. Swap it to widen what a step may return or to speed the encoding
|
|
375
|
+
up; what it MUST keep is the round trip, since a resumed pass reads what it produced.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
database: Database
|
|
379
|
+
codec: CheckpointCodec[str] = JSON
|
|
380
|
+
|
|
381
|
+
async def load(self, workflow: str) -> dict[str, object]:
|
|
382
|
+
rows = await self.database.run(lambda connection: connection.execute(LOAD, (workflow,)).fetchall())
|
|
383
|
+
return {step: self.codec.decode(encoded) for step, encoded in rows}
|
|
384
|
+
|
|
385
|
+
async def claim(self, workflow: str, lease: timedelta) -> Pass | None:
|
|
386
|
+
taken = await self.database.run(
|
|
387
|
+
lambda connection: connection.execute(
|
|
388
|
+
CLAIM,
|
|
389
|
+
{"workflow": workflow, "lease": lease.total_seconds()},
|
|
390
|
+
).fetchone()
|
|
391
|
+
)
|
|
392
|
+
if taken is None:
|
|
393
|
+
return None
|
|
394
|
+
return Pass(workflow=workflow, token=int(taken[0]))
|
|
395
|
+
|
|
396
|
+
async def record(self, holder: Pass, key: str, value: object) -> Recorded:
|
|
397
|
+
encoded = self.codec.encode(value)
|
|
398
|
+
stored = await self.database.run(
|
|
399
|
+
lambda connection: connection.execute(
|
|
400
|
+
RECORD,
|
|
401
|
+
{
|
|
402
|
+
"workflow": holder.workflow,
|
|
403
|
+
"step": key,
|
|
404
|
+
"value": encoded,
|
|
405
|
+
"token": holder.token,
|
|
406
|
+
},
|
|
407
|
+
).fetchone()
|
|
408
|
+
)
|
|
409
|
+
if stored is None:
|
|
410
|
+
# The statement wrote nothing, which happens for exactly one reason: the
|
|
411
|
+
# `WHERE` that guards the insert compared this pass's token against the fence
|
|
412
|
+
# and refused it. (A missing claim row would land here too, and a `Pass` is
|
|
413
|
+
# only ever handed out by a `claim` that wrote one.)
|
|
414
|
+
raise Fenced(f"{holder.workflow!r} moved on while this pass held it")
|
|
415
|
+
return Recorded(value=self.codec.decode(stored[0]), first=bool(stored[1]))
|
|
416
|
+
|
|
417
|
+
async def transact(self, holder: Pass, key: str, effect: SqliteEffect) -> object:
|
|
418
|
+
"""
|
|
419
|
+
Run `effect` and record it in one transaction, so the step happens once.
|
|
420
|
+
|
|
421
|
+
The order is the other stores': fence first, because a superseded pass must not
|
|
422
|
+
act; then the *existence* check, because a step already recorded must not run
|
|
423
|
+
again, which is what makes a replay perform nothing at all; then the effect; then
|
|
424
|
+
the record. `BEGIN IMMEDIATE` holds the write lock across all four, so no other
|
|
425
|
+
writer can land between them and any exception rolls back the effect along with
|
|
426
|
+
its record.
|
|
427
|
+
|
|
428
|
+
The effect's result is written and read back through the codec rather than
|
|
429
|
+
returned as it came, so it round-trips exactly as a later pass will see it.
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
def one_commit(cursor: sqlite3.Cursor) -> object:
|
|
433
|
+
fence = cursor.execute(FENCE, (holder.workflow,)).fetchone()
|
|
434
|
+
if fence is None or holder.token < fence[0]:
|
|
435
|
+
raise Fenced(f"{holder.workflow!r} moved on while this pass held it")
|
|
436
|
+
recorded = cursor.execute(ALREADY, (holder.workflow, key)).fetchone()
|
|
437
|
+
if recorded is not None:
|
|
438
|
+
return self.codec.decode(recorded[0])
|
|
439
|
+
written = self.codec.encode(effect(cursor))
|
|
440
|
+
cursor.execute(WRITE, (holder.workflow, key, written))
|
|
441
|
+
return self.codec.decode(written)
|
|
442
|
+
|
|
443
|
+
return await self.database.run(lambda connection: transacted(connection, one_commit))
|
|
444
|
+
|
|
445
|
+
async def supply(self, workflow: str, key: str, value: object) -> object:
|
|
446
|
+
stored = await self.database.run(
|
|
447
|
+
lambda connection: connection.execute(
|
|
448
|
+
SUPPLY,
|
|
449
|
+
{"workflow": workflow, "step": key, "value": self.codec.encode(value)},
|
|
450
|
+
).fetchone()
|
|
451
|
+
)
|
|
452
|
+
return self.codec.decode(cast(tuple[str], stored)[0])
|
|
453
|
+
|
|
454
|
+
async def release(self, holder: Pass) -> None:
|
|
455
|
+
await self.database.run(lambda connection: connection.execute(RELEASE, (holder.workflow, holder.token)))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@dataclass(frozen=True, slots=True)
|
|
459
|
+
class SqliteScheduler:
|
|
460
|
+
"""
|
|
461
|
+
`Scheduler` as one table, each row scored by when its workflow becomes visible.
|
|
462
|
+
|
|
463
|
+
A drop-in for every other queue here, and modelled on the same visibility scheme:
|
|
464
|
+
queued now is a `visible_at` in the past, sleeping is one in the future, and being
|
|
465
|
+
worked on is one a lease ahead, so `wake_due`, `reclaim`, and `prepare`'s queue half
|
|
466
|
+
all have nothing to do.
|
|
467
|
+
|
|
468
|
+
It polls, like the other visibility-scored queues, so the poll interval is a floor
|
|
469
|
+
under how fast anything starts. SQLite offers no blocking read and no notification a
|
|
470
|
+
process outside this one can wait on, so unlike the Postgres store there is not even
|
|
471
|
+
a `LISTEN`/`NOTIFY` left on the table: within one process an `asyncio.Event` would do
|
|
472
|
+
it, across processes on one machine it would take a filesystem watch, and neither is
|
|
473
|
+
here.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
database: Database
|
|
477
|
+
namespace: str = "workflow"
|
|
478
|
+
# How long a taken workflow stays invisible, and so how long after a process dies
|
|
479
|
+
# before another picks its workflow up. `worker.work` reads it and claims the workflow
|
|
480
|
+
# for the same span, which is the whole reason it is one number: a workflow that
|
|
481
|
+
# becomes visible before its claim lapses is taken by a worker that cannot write to it
|
|
482
|
+
# yet. This is the knob for a deployment whose passes take longer than a minute.
|
|
483
|
+
lease: timedelta = LEASE
|
|
484
|
+
poll: timedelta = POLL
|
|
485
|
+
# Only `make_ready` reads it: "visible now" is the one time a caller names, where the
|
|
486
|
+
# lease is measured by the database (in `TAKE`) and a deadline was chosen by the
|
|
487
|
+
# workflow itself. Injected so a test can place a wakeup in a clock it controls.
|
|
488
|
+
now: Callable[[], datetime] = now_utc
|
|
489
|
+
# The two durations as the numbers SQLite and `asyncio.sleep` want, rendered once
|
|
490
|
+
# rather than per iteration of `next_ready`'s poll loop, which is the one place here
|
|
491
|
+
# that runs more than once per unit of work.
|
|
492
|
+
lease_seconds: float = field(init=False, repr=False, compare=False)
|
|
493
|
+
poll_seconds: float = field(init=False, repr=False, compare=False)
|
|
494
|
+
|
|
495
|
+
def __post_init__(self) -> None:
|
|
496
|
+
check_duration("a lease", self.lease)
|
|
497
|
+
check_duration("a poll interval", self.poll)
|
|
498
|
+
object.__setattr__(self, "lease_seconds", self.lease.total_seconds())
|
|
499
|
+
object.__setattr__(self, "poll_seconds", self.poll.total_seconds())
|
|
500
|
+
|
|
501
|
+
async def prepare(self) -> None:
|
|
502
|
+
"""Create the tables, which every worker does at boot and all but the first find done."""
|
|
503
|
+
await migrate(self.database)
|
|
504
|
+
|
|
505
|
+
async def make_ready(self, workflow: str) -> None:
|
|
506
|
+
await self.schedule(workflow, self.now())
|
|
507
|
+
|
|
508
|
+
async def wake_at(self, delivery: Delivery, when: datetime) -> None:
|
|
509
|
+
"""
|
|
510
|
+
Suspend the workflow until `when`, unless something asked for a pass meanwhile.
|
|
511
|
+
|
|
512
|
+
The receipt is the visibility this pass took, so anything that rescheduled the
|
|
513
|
+
workflow since (a confirmation, another worker taking over an overrun) wrote a
|
|
514
|
+
different one and this leaves it be. Which is the right answer rather than a
|
|
515
|
+
concession: the deadline lives in the workflow's checkpoint, so the pass that runs
|
|
516
|
+
sooner reaches the same `sleep` and writes it again.
|
|
517
|
+
"""
|
|
518
|
+
await self.database.run(
|
|
519
|
+
lambda connection: connection.execute(
|
|
520
|
+
SUSPEND,
|
|
521
|
+
(when.timestamp(), self.namespace, delivery.workflow, float(delivery.receipt)),
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
async def schedule(self, workflow: str, visible_at: datetime) -> None:
|
|
526
|
+
await self.database.run(
|
|
527
|
+
lambda connection: connection.execute(
|
|
528
|
+
SCHEDULE,
|
|
529
|
+
{"namespace": self.namespace, "workflow": workflow, "visible_at": visible_at.timestamp()},
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
async def wake_due(self, now: datetime) -> tuple[str, ...]:
|
|
534
|
+
"""Nothing to do: a workflow whose `visible_at` has passed is already visible."""
|
|
535
|
+
return ()
|
|
536
|
+
|
|
537
|
+
async def next_ready(self, within: timedelta) -> Delivery | None:
|
|
538
|
+
"""The next visible workflow, waiting up to `within` for one to appear."""
|
|
539
|
+
deadline = monotonic() + within.total_seconds()
|
|
540
|
+
while True:
|
|
541
|
+
taken = await self.database.run(
|
|
542
|
+
lambda connection: connection.execute(
|
|
543
|
+
TAKE,
|
|
544
|
+
{"namespace": self.namespace, "lease": self.lease_seconds},
|
|
545
|
+
).fetchone()
|
|
546
|
+
)
|
|
547
|
+
if taken is not None:
|
|
548
|
+
workflow, visible_at = taken
|
|
549
|
+
# The receipt is the visibility this take wrote, rendered so it is a value
|
|
550
|
+
# rather than a place: `done` compares it back and declines to remove a row
|
|
551
|
+
# anything else has since rescheduled.
|
|
552
|
+
return Delivery(workflow=workflow, receipt=repr(float(visible_at)))
|
|
553
|
+
remaining = deadline - monotonic()
|
|
554
|
+
if remaining <= 0:
|
|
555
|
+
return None
|
|
556
|
+
await asyncio.sleep(min(self.poll_seconds, remaining))
|
|
557
|
+
|
|
558
|
+
async def reclaim(self, idle: timedelta) -> Delivery | None:
|
|
559
|
+
"""Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
async def done(self, delivery: Delivery) -> None:
|
|
563
|
+
"""
|
|
564
|
+
Drop the workflow, unless something asked for another pass while this one ran.
|
|
565
|
+
|
|
566
|
+
The receipt is the visibility this pass took, so anything that rescheduled the
|
|
567
|
+
workflow meanwhile (a confirmation, this pass's own `wake_at`, another worker
|
|
568
|
+
taking over an overrun) wrote a different one and this leaves it alone.
|
|
569
|
+
"""
|
|
570
|
+
await self.database.run(
|
|
571
|
+
lambda connection: connection.execute(
|
|
572
|
+
FINISH,
|
|
573
|
+
(self.namespace, delivery.workflow, float(delivery.receipt)),
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@dataclass(frozen=True, slots=True)
|
|
579
|
+
class SqliteDurable:
|
|
580
|
+
"""
|
|
581
|
+
A `Durable` whose two stores are one file, so `arrive` is a single commit.
|
|
582
|
+
|
|
583
|
+
The strongest form of the guarantee, reached by the least machinery: there is nothing
|
|
584
|
+
to co-locate, no pool to share by accident, and no sharding to grow into. The two
|
|
585
|
+
stores MUST hold the same `Database`, checked at construction, which here is less a
|
|
586
|
+
warning about distributed transactions than a way of saying that two SQLite files are
|
|
587
|
+
two datastores however adjacent they sit on disk.
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
checkpointer: SqliteCheckpointer
|
|
591
|
+
scheduler: SqliteScheduler
|
|
592
|
+
|
|
593
|
+
def __post_init__(self) -> None:
|
|
594
|
+
if self.checkpointer.database is not self.scheduler.database:
|
|
595
|
+
raise ValueError("a SqliteDurable's two stores must share one database, or `arrive` is not one commit")
|
|
596
|
+
|
|
597
|
+
async def arrive(self, workflow: str, key: str, value: object) -> object:
|
|
598
|
+
"""Record the value and make the workflow ready, together or not at all."""
|
|
599
|
+
visible_at = self.scheduler.now().timestamp()
|
|
600
|
+
|
|
601
|
+
def one_commit(cursor: sqlite3.Cursor) -> object:
|
|
602
|
+
stored = cursor.execute(
|
|
603
|
+
SUPPLY,
|
|
604
|
+
{"workflow": workflow, "step": key, "value": self.checkpointer.codec.encode(value)},
|
|
605
|
+
).fetchone()
|
|
606
|
+
cursor.execute(
|
|
607
|
+
SCHEDULE,
|
|
608
|
+
{"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": visible_at},
|
|
609
|
+
)
|
|
610
|
+
return self.checkpointer.codec.decode(cast(tuple[str], stored)[0])
|
|
611
|
+
|
|
612
|
+
return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
|
|
@@ -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-sqlite"
|
|
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-sqlite"
|
|
7
|
-
version = "0.0.0"
|
|
8
|
-
description = "Placeholder reserving the PyPI project name; the first real release supersedes it."
|
|
9
|
-
requires-python = ">=3.9"
|