without-durability-sqlite 0.0.5__tar.gz → 0.0.7__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.5 → without_durability_sqlite-0.0.7}/PKG-INFO +5 -4
- {without_durability_sqlite-0.0.5 → without_durability_sqlite-0.0.7}/README.md +2 -2
- {without_durability_sqlite-0.0.5 → without_durability_sqlite-0.0.7}/pyproject.toml +8 -2
- {without_durability_sqlite-0.0.5 → without_durability_sqlite-0.0.7}/pyproject.toml.orig +4 -2
- {without_durability_sqlite-0.0.5 → without_durability_sqlite-0.0.7}/src/without_durability_sqlite/store.py +94 -9
- {without_durability_sqlite-0.0.5 → without_durability_sqlite-0.0.7}/src/without_durability_sqlite/__init__.py +0 -0
- {without_durability_sqlite-0.0.5 → without_durability_sqlite-0.0.7}/src/without_durability_sqlite/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: without-durability-sqlite
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.7
|
|
4
4
|
Summary: A without-durability checkpoint store and queue backed by one SQLite file, with no server and no third-party driver.
|
|
5
5
|
Author: Josh Karpel
|
|
6
6
|
Author-email: Josh Karpel <josh.karpel@gmail.com>
|
|
@@ -14,7 +14,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Classifier: Topic :: Software Development :: Libraries
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
|
-
Requires-Dist: without-
|
|
17
|
+
Requires-Dist: without-async==0.0.7
|
|
18
|
+
Requires-Dist: without-durability==0.0.7
|
|
18
19
|
Requires-Python: >=3.14
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
|
|
@@ -43,8 +44,8 @@ There is one writer at a time by construction, so `BEGIN IMMEDIATE` takes the
|
|
|
43
44
|
write lock for the whole transaction and the fence check and the write it guards
|
|
44
45
|
cannot be interleaved: Postgres needs `FOR UPDATE` on the claim row to get that
|
|
45
46
|
and Redis needs a Lua script, while here the transaction *is* the exclusion. And
|
|
46
|
-
there is nothing to co-locate, because the datastore is a file, so `transact
|
|
47
|
-
`arrive` reach every table an application keeps in it. That last one is the same
|
|
47
|
+
there is nothing to co-locate, because the datastore is a file, so `transact`,
|
|
48
|
+
`arrive`, and `deliver` reach every table an application keeps in it. That last one is the same
|
|
48
49
|
guarantee DBOS gets from Postgres, for an application that never needed Postgres.
|
|
49
50
|
|
|
50
51
|
What it costs is the shape of the whole thing: one machine. Every process sharing
|
|
@@ -23,8 +23,8 @@ There is one writer at a time by construction, so `BEGIN IMMEDIATE` takes the
|
|
|
23
23
|
write lock for the whole transaction and the fence check and the write it guards
|
|
24
24
|
cannot be interleaved: Postgres needs `FOR UPDATE` on the claim row to get that
|
|
25
25
|
and Redis needs a Lua script, while here the transaction *is* the exclusion. And
|
|
26
|
-
there is nothing to co-locate, because the datastore is a file, so `transact
|
|
27
|
-
`arrive` reach every table an application keeps in it. That last one is the same
|
|
26
|
+
there is nothing to co-locate, because the datastore is a file, so `transact`,
|
|
27
|
+
`arrive`, and `deliver` reach every table an application keeps in it. That last one is the same
|
|
28
28
|
guarantee DBOS gets from Postgres, for an application that never needed Postgres.
|
|
29
29
|
|
|
30
30
|
What it costs is the shape of the whole thing: one machine. Every process sharing
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "without-durability-sqlite"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.7"
|
|
8
8
|
description = "A without-durability checkpoint store and queue backed by one SQLite file, with no server and no third-party driver."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -20,11 +20,17 @@ classifiers = [
|
|
|
20
20
|
"Topic :: Software Development :: Libraries",
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
|
-
dependencies = [
|
|
23
|
+
dependencies = [
|
|
24
|
+
"without-async==0.0.7",
|
|
25
|
+
"without-durability==0.0.7",
|
|
26
|
+
]
|
|
24
27
|
|
|
25
28
|
[[project.authors]]
|
|
26
29
|
name = "Josh Karpel"
|
|
27
30
|
email = "josh.karpel@gmail.com"
|
|
28
31
|
|
|
32
|
+
[tool.uv.sources.without-async]
|
|
33
|
+
workspace = true
|
|
34
|
+
|
|
29
35
|
[tool.uv.sources.without-durability]
|
|
30
36
|
workspace = true
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "without-durability-sqlite"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.7"
|
|
8
8
|
description = "A without-durability checkpoint store and queue backed by one SQLite file, with no server and no third-party driver."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -24,8 +24,10 @@ classifiers = [
|
|
|
24
24
|
"Typing :: Typed",
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"without-
|
|
27
|
+
"without-async==0.0.7",
|
|
28
|
+
"without-durability==0.0.7",
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[tool.uv.sources]
|
|
32
|
+
without-async = { workspace = true }
|
|
31
33
|
without-durability = { workspace = true }
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# be interleaved with anything. Postgres needs `FOR UPDATE` on the claim row to get
|
|
12
12
|
# that, because there readers and writers run concurrently and a statement's snapshot
|
|
13
13
|
# can be stale; Redis needs a Lua script. Here the transaction *is* the exclusion.
|
|
14
|
-
# - There is nothing to co-locate. `transact` and `
|
|
14
|
+
# - There is nothing to co-locate. `transact`, `arrive`, and `deliver` reach the whole datastore
|
|
15
15
|
# because the datastore is a file, so the question the other two stores have to keep
|
|
16
16
|
# asking (are these two writes in one local commit?) has one answer and it is yes.
|
|
17
17
|
#
|
|
@@ -65,10 +65,14 @@ from pathlib import Path
|
|
|
65
65
|
from time import monotonic
|
|
66
66
|
from typing import cast
|
|
67
67
|
|
|
68
|
+
from without_async import Milliseconds
|
|
68
69
|
from without_durability.codec import JSON
|
|
69
70
|
from without_durability.codec import CheckpointCodec
|
|
71
|
+
from without_durability.interfaces import INBOX
|
|
72
|
+
from without_durability.interfaces import INBOX_DIGITS
|
|
70
73
|
from without_durability.interfaces import LEASE
|
|
71
74
|
from without_durability.interfaces import Delivery
|
|
75
|
+
from without_durability.interfaces import Entry
|
|
72
76
|
from without_durability.interfaces import Fenced
|
|
73
77
|
from without_durability.interfaces import Pass
|
|
74
78
|
from without_durability.interfaces import Recorded
|
|
@@ -81,22 +85,48 @@ from without_durability.stepwise import now_utc
|
|
|
81
85
|
# the worker takes for exactly as long).
|
|
82
86
|
POLL = timedelta(milliseconds=50)
|
|
83
87
|
|
|
88
|
+
# How long a process finding the write lock taken waits for it, in the unit
|
|
89
|
+
# `PRAGMA busy_timeout` carries. A frozen count is a value, so one serves every caller.
|
|
90
|
+
BUSY_TIMEOUT = Milliseconds(5_000)
|
|
91
|
+
|
|
84
92
|
# `value` is TEXT rather than a richer type, which is the same shape the Redis store's
|
|
85
93
|
# hash field has and leaves the same question open: what goes *in* the text is the
|
|
86
94
|
# store's injected `CheckpointCodec`, defaulting to JSON because that is what makes a
|
|
87
|
-
# checkpoint readable by anything that can open the file.
|
|
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.
|
|
95
|
+
# checkpoint readable by anything that can open the file.
|
|
90
96
|
#
|
|
91
97
|
# `NOT NULL` on `value` keeps "no row" and "a row holding JSON null" distinguishable, so
|
|
92
98
|
# a step that legitimately records `None` is not read back as a step that never ran.
|
|
99
|
+
#
|
|
100
|
+
# `seq` is what `load`'s ordering guarantee rests on, and it is the same column Postgres
|
|
101
|
+
# names: assigned on insert, in insertion order, and left alone by the conflict update
|
|
102
|
+
# below, which is exactly the property the guarantee needs. The writer that first recorded
|
|
103
|
+
# a step decides where it sits, and a later write that loses moves neither the value nor
|
|
104
|
+
# the position. Nothing else here is a candidate, since the only other order on offer is
|
|
105
|
+
# the unique index's, which is step name: a different question with a plausible enough
|
|
106
|
+
# answer to pass a careless test.
|
|
107
|
+
#
|
|
108
|
+
# `INTEGER PRIMARY KEY` makes it an alias for the rowid rather than a column beside one,
|
|
109
|
+
# so it costs no storage and needs no sequence. Naming it is not decoration either. SQLite
|
|
110
|
+
# reserves the right to renumber rowids in "any tables that do not have an explicit INTEGER
|
|
111
|
+
# PRIMARY KEY" when a database is `VACUUM`ed, and this store hands an operator a file it
|
|
112
|
+
# invites any tool to open, so the guarantee would otherwise rest on a promise SQLite
|
|
113
|
+
# declines to make. Declaring the column is what puts this table outside that sentence.
|
|
114
|
+
#
|
|
115
|
+
# `(workflow, step)` is `UNIQUE` rather than the primary key because a table gets one
|
|
116
|
+
# primary key and `seq` is now it. Nothing else changes: both columns are `NOT NULL`, so
|
|
117
|
+
# the constraint admits exactly the rows the primary key did, and it is still what the
|
|
118
|
+
# upserts below name as their conflict target.
|
|
119
|
+
#
|
|
120
|
+
# The claim and queue tables stay `WITHOUT ROWID` because each is addressed by its primary
|
|
121
|
+
# key and never by a position, so for those the indirection would be pure overhead.
|
|
93
122
|
SCHEMA = """
|
|
94
123
|
CREATE TABLE IF NOT EXISTS workflow_checkpoint (
|
|
124
|
+
seq INTEGER PRIMARY KEY,
|
|
95
125
|
workflow TEXT NOT NULL,
|
|
96
126
|
step TEXT NOT NULL,
|
|
97
127
|
value TEXT NOT NULL,
|
|
98
|
-
|
|
99
|
-
)
|
|
128
|
+
UNIQUE (workflow, step)
|
|
129
|
+
);
|
|
100
130
|
|
|
101
131
|
CREATE TABLE IF NOT EXISTS workflow_claim (
|
|
102
132
|
workflow TEXT PRIMARY KEY,
|
|
@@ -164,10 +194,36 @@ ON CONFLICT (workflow, step) DO UPDATE SET value = workflow_checkpoint.value
|
|
|
164
194
|
RETURNING value
|
|
165
195
|
"""
|
|
166
196
|
|
|
197
|
+
# `supply` under a key this statement mints instead of one the caller brought: the append
|
|
198
|
+
# that puts a message in a workflow's inbox.
|
|
199
|
+
#
|
|
200
|
+
# The number is the highest `seq` in the table plus one, which is global rather than per
|
|
201
|
+
# workflow and so leaves gaps in any one workflow's run of keys. That is what the contract
|
|
202
|
+
# allows and what makes this a single statement: a per-workflow count would be a second
|
|
203
|
+
# read, and reading a maximum and then inserting against it is the exact race that would
|
|
204
|
+
# need a transaction to close. There are no concurrent writers here to lose it to, since
|
|
205
|
+
# SQLite admits one, but the store that has them (Postgres) reaches for the same shape and
|
|
206
|
+
# the two are easier to hold in one head this way.
|
|
207
|
+
#
|
|
208
|
+
# There is no `ON CONFLICT` clause and deliberately none: the key is fresh by construction,
|
|
209
|
+
# so a collision means the numbering is broken and a `UNIQUE` violation is the loud version
|
|
210
|
+
# of that. An upsert here would quietly hand back somebody else's message instead.
|
|
211
|
+
APPEND = f"""
|
|
212
|
+
INSERT INTO workflow_checkpoint (workflow, step, value)
|
|
213
|
+
SELECT
|
|
214
|
+
:workflow,
|
|
215
|
+
'{INBOX}' || printf('%0{INBOX_DIGITS}d', COALESCE((SELECT MAX(seq) FROM workflow_checkpoint), 0) + 1),
|
|
216
|
+
:value
|
|
217
|
+
RETURNING step, value
|
|
218
|
+
"""
|
|
219
|
+
|
|
167
220
|
FENCE = "SELECT token FROM workflow_claim WHERE workflow = ?"
|
|
168
221
|
ALREADY = "SELECT value FROM workflow_checkpoint WHERE workflow = ? AND step = ?"
|
|
169
222
|
WRITE = "INSERT INTO workflow_checkpoint (workflow, step, value) VALUES (?, ?, ?)"
|
|
170
|
-
|
|
223
|
+
# `ORDER BY seq` is the whole of the ordering guarantee, and it is load-bearing rather
|
|
224
|
+
# than a formality: `WHERE workflow = ?` is served by the unique index on
|
|
225
|
+
# `(workflow, step)`, so without it the rows come back sorted by step name.
|
|
226
|
+
LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ? ORDER BY seq"
|
|
171
227
|
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
172
228
|
# number up and a pass that comes back from the dead still loses.
|
|
173
229
|
RELEASE = "UPDATE workflow_claim SET held_until = unixepoch('now', 'subsec') WHERE workflow = ? AND token = ?"
|
|
@@ -302,7 +358,7 @@ class Database:
|
|
|
302
358
|
await asyncio.to_thread(self.connection.close)
|
|
303
359
|
|
|
304
360
|
|
|
305
|
-
def connect(path: Path | str, *, timeout:
|
|
361
|
+
def connect(path: Path | str, *, timeout: Milliseconds = BUSY_TIMEOUT) -> Database:
|
|
306
362
|
"""
|
|
307
363
|
Open the database this store runs on, configured for durability rather than speed.
|
|
308
364
|
|
|
@@ -323,7 +379,7 @@ def connect(path: Path | str, *, timeout: timedelta = timedelta(seconds=5)) -> D
|
|
|
323
379
|
connection = sqlite3.connect(path, autocommit=True, check_same_thread=False)
|
|
324
380
|
connection.execute("PRAGMA journal_mode = WAL")
|
|
325
381
|
connection.execute("PRAGMA synchronous = FULL")
|
|
326
|
-
connection.execute(f"PRAGMA busy_timeout = {
|
|
382
|
+
connection.execute(f"PRAGMA busy_timeout = {timeout.count}")
|
|
327
383
|
return Database(connection=connection)
|
|
328
384
|
|
|
329
385
|
|
|
@@ -480,6 +536,17 @@ class SqliteCheckpointer:
|
|
|
480
536
|
)
|
|
481
537
|
return self.codec.decode(cast(tuple[str], stored)[0])
|
|
482
538
|
|
|
539
|
+
async def append(self, workflow: str, value: object) -> Entry:
|
|
540
|
+
"""File `value` in this workflow's inbox, under the next key in the table."""
|
|
541
|
+
stored = await self.database.run(
|
|
542
|
+
lambda connection: connection.execute(
|
|
543
|
+
APPEND,
|
|
544
|
+
{"workflow": workflow, "value": self.codec.encode(value)},
|
|
545
|
+
).fetchone()
|
|
546
|
+
)
|
|
547
|
+
key, encoded = cast(tuple[str, str], stored)
|
|
548
|
+
return Entry(key=key, value=self.codec.decode(encoded))
|
|
549
|
+
|
|
483
550
|
async def release(self, holder: Pass) -> None:
|
|
484
551
|
await self.database.run(lambda connection: connection.execute(RELEASE, (holder.workflow, holder.token)))
|
|
485
552
|
|
|
@@ -639,3 +706,21 @@ class SqliteDurable:
|
|
|
639
706
|
return self.checkpointer.codec.decode(cast(tuple[str], stored)[0])
|
|
640
707
|
|
|
641
708
|
return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
|
|
709
|
+
|
|
710
|
+
async def deliver(self, workflow: str, value: object) -> Entry:
|
|
711
|
+
"""Append the message and make the workflow ready, together or not at all."""
|
|
712
|
+
visible_at = self.scheduler.now().timestamp()
|
|
713
|
+
|
|
714
|
+
def one_commit(cursor: sqlite3.Cursor) -> Entry:
|
|
715
|
+
stored = cursor.execute(
|
|
716
|
+
APPEND,
|
|
717
|
+
{"workflow": workflow, "value": self.checkpointer.codec.encode(value)},
|
|
718
|
+
).fetchone()
|
|
719
|
+
cursor.execute(
|
|
720
|
+
SCHEDULE,
|
|
721
|
+
{"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": visible_at},
|
|
722
|
+
)
|
|
723
|
+
key, encoded = cast(tuple[str, str], stored)
|
|
724
|
+
return Entry(key=key, value=self.checkpointer.codec.decode(encoded))
|
|
725
|
+
|
|
726
|
+
return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
|
|
File without changes
|
|
File without changes
|