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.
@@ -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,5 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: without-durability-sqlite
3
- Version: 0.0.0
4
- Summary: Placeholder reserving the PyPI project name; the first real release supersedes it.
5
- 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"
@@ -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"