without-durability-sqlite 0.0.6__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.6 → without_durability_sqlite-0.0.7}/PKG-INFO +5 -5
- {without_durability_sqlite-0.0.6 → without_durability_sqlite-0.0.7}/README.md +2 -2
- {without_durability_sqlite-0.0.6 → without_durability_sqlite-0.0.7}/pyproject.toml +3 -3
- {without_durability_sqlite-0.0.6 → without_durability_sqlite-0.0.7}/pyproject.toml.orig +3 -3
- {without_durability_sqlite-0.0.6 → without_durability_sqlite-0.0.7}/src/without_durability_sqlite/store.py +87 -7
- {without_durability_sqlite-0.0.6 → without_durability_sqlite-0.0.7}/src/without_durability_sqlite/__init__.py +0 -0
- {without_durability_sqlite-0.0.6 → 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,8 +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-async==0.0.
|
|
18
|
-
Requires-Dist: without-durability==0.0.
|
|
17
|
+
Requires-Dist: without-async==0.0.7
|
|
18
|
+
Requires-Dist: without-durability==0.0.7
|
|
19
19
|
Requires-Python: >=3.14
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
|
|
@@ -44,8 +44,8 @@ There is one writer at a time by construction, so `BEGIN IMMEDIATE` takes the
|
|
|
44
44
|
write lock for the whole transaction and the fence check and the write it guards
|
|
45
45
|
cannot be interleaved: Postgres needs `FOR UPDATE` on the claim row to get that
|
|
46
46
|
and Redis needs a Lua script, while here the transaction *is* the exclusion. And
|
|
47
|
-
there is nothing to co-locate, because the datastore is a file, so `transact
|
|
48
|
-
`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
|
|
49
49
|
guarantee DBOS gets from Postgres, for an application that never needed Postgres.
|
|
50
50
|
|
|
51
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"
|
|
@@ -21,8 +21,8 @@ classifiers = [
|
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
|
-
"without-async==0.0.
|
|
25
|
-
"without-durability==0.0.
|
|
24
|
+
"without-async==0.0.7",
|
|
25
|
+
"without-durability==0.0.7",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[[project.authors]]
|
|
@@ -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,8 @@ classifiers = [
|
|
|
24
24
|
"Typing :: Typed",
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"without-async==0.0.
|
|
28
|
-
"without-durability==0.0.
|
|
27
|
+
"without-async==0.0.7",
|
|
28
|
+
"without-durability==0.0.7",
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
[tool.uv.sources]
|
|
@@ -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
|
#
|
|
@@ -68,8 +68,11 @@ from typing import cast
|
|
|
68
68
|
from without_async import Milliseconds
|
|
69
69
|
from without_durability.codec import JSON
|
|
70
70
|
from without_durability.codec import CheckpointCodec
|
|
71
|
+
from without_durability.interfaces import INBOX
|
|
72
|
+
from without_durability.interfaces import INBOX_DIGITS
|
|
71
73
|
from without_durability.interfaces import LEASE
|
|
72
74
|
from without_durability.interfaces import Delivery
|
|
75
|
+
from without_durability.interfaces import Entry
|
|
73
76
|
from without_durability.interfaces import Fenced
|
|
74
77
|
from without_durability.interfaces import Pass
|
|
75
78
|
from without_durability.interfaces import Recorded
|
|
@@ -89,19 +92,41 @@ BUSY_TIMEOUT = Milliseconds(5_000)
|
|
|
89
92
|
# `value` is TEXT rather than a richer type, which is the same shape the Redis store's
|
|
90
93
|
# hash field has and leaves the same question open: what goes *in* the text is the
|
|
91
94
|
# store's injected `CheckpointCodec`, defaulting to JSON because that is what makes a
|
|
92
|
-
# checkpoint readable by anything that can open the file.
|
|
93
|
-
# one of these tables is addressed by its primary key and never by a rowid, so the extra
|
|
94
|
-
# indirection would be pure overhead.
|
|
95
|
+
# checkpoint readable by anything that can open the file.
|
|
95
96
|
#
|
|
96
97
|
# `NOT NULL` on `value` keeps "no row" and "a row holding JSON null" distinguishable, so
|
|
97
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.
|
|
98
122
|
SCHEMA = """
|
|
99
123
|
CREATE TABLE IF NOT EXISTS workflow_checkpoint (
|
|
124
|
+
seq INTEGER PRIMARY KEY,
|
|
100
125
|
workflow TEXT NOT NULL,
|
|
101
126
|
step TEXT NOT NULL,
|
|
102
127
|
value TEXT NOT NULL,
|
|
103
|
-
|
|
104
|
-
)
|
|
128
|
+
UNIQUE (workflow, step)
|
|
129
|
+
);
|
|
105
130
|
|
|
106
131
|
CREATE TABLE IF NOT EXISTS workflow_claim (
|
|
107
132
|
workflow TEXT PRIMARY KEY,
|
|
@@ -169,10 +194,36 @@ ON CONFLICT (workflow, step) DO UPDATE SET value = workflow_checkpoint.value
|
|
|
169
194
|
RETURNING value
|
|
170
195
|
"""
|
|
171
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
|
+
|
|
172
220
|
FENCE = "SELECT token FROM workflow_claim WHERE workflow = ?"
|
|
173
221
|
ALREADY = "SELECT value FROM workflow_checkpoint WHERE workflow = ? AND step = ?"
|
|
174
222
|
WRITE = "INSERT INTO workflow_checkpoint (workflow, step, value) VALUES (?, ?, ?)"
|
|
175
|
-
|
|
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"
|
|
176
227
|
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
177
228
|
# number up and a pass that comes back from the dead still loses.
|
|
178
229
|
RELEASE = "UPDATE workflow_claim SET held_until = unixepoch('now', 'subsec') WHERE workflow = ? AND token = ?"
|
|
@@ -485,6 +536,17 @@ class SqliteCheckpointer:
|
|
|
485
536
|
)
|
|
486
537
|
return self.codec.decode(cast(tuple[str], stored)[0])
|
|
487
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
|
+
|
|
488
550
|
async def release(self, holder: Pass) -> None:
|
|
489
551
|
await self.database.run(lambda connection: connection.execute(RELEASE, (holder.workflow, holder.token)))
|
|
490
552
|
|
|
@@ -644,3 +706,21 @@ class SqliteDurable:
|
|
|
644
706
|
return self.checkpointer.codec.decode(cast(tuple[str], stored)[0])
|
|
645
707
|
|
|
646
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
|