without-durability-sqlite 0.0.8__tar.gz → 0.0.10__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.8 → without_durability_sqlite-0.0.10}/PKG-INFO +5 -5
- {without_durability_sqlite-0.0.8 → without_durability_sqlite-0.0.10}/README.md +2 -2
- {without_durability_sqlite-0.0.8 → without_durability_sqlite-0.0.10}/pyproject.toml +3 -3
- {without_durability_sqlite-0.0.8 → without_durability_sqlite-0.0.10}/pyproject.toml.orig +3 -3
- {without_durability_sqlite-0.0.8 → without_durability_sqlite-0.0.10}/src/without_durability_sqlite/store.py +172 -25
- {without_durability_sqlite-0.0.8 → without_durability_sqlite-0.0.10}/src/without_durability_sqlite/__init__.py +0 -0
- {without_durability_sqlite-0.0.8 → without_durability_sqlite-0.0.10}/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.10
|
|
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.10
|
|
18
|
+
Requires-Dist: without-durability==0.0.10
|
|
19
19
|
Requires-Python: >=3.14
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
|
|
@@ -42,8 +42,8 @@ a cluster, a database server, or a dependency.
|
|
|
42
42
|
Two questions the other stores have to answer carefully settle themselves here.
|
|
43
43
|
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
|
-
cannot be interleaved: Postgres needs
|
|
46
|
-
|
|
45
|
+
cannot be interleaved: Postgres needs a row lock on the claim to get that and
|
|
46
|
+
Redis needs a Lua script, while here the transaction *is* the exclusion. And
|
|
47
47
|
there is nothing to co-locate, because the datastore is a file, so `transact`,
|
|
48
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.
|
|
@@ -21,8 +21,8 @@ a cluster, a database server, or a dependency.
|
|
|
21
21
|
Two questions the other stores have to answer carefully settle themselves here.
|
|
22
22
|
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
|
-
cannot be interleaved: Postgres needs
|
|
25
|
-
|
|
24
|
+
cannot be interleaved: Postgres needs a row lock on the claim to get that and
|
|
25
|
+
Redis needs a Lua script, while here the transaction *is* the exclusion. And
|
|
26
26
|
there is nothing to co-locate, because the datastore is a file, so `transact`,
|
|
27
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.
|
|
@@ -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.10"
|
|
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.10",
|
|
25
|
+
"without-durability==0.0.10",
|
|
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.10"
|
|
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.10",
|
|
28
|
+
"without-durability==0.0.10",
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
[tool.uv.sources]
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
#
|
|
9
9
|
# - There is one writer at a time, by construction. `BEGIN IMMEDIATE` takes the write
|
|
10
10
|
# lock for the whole transaction, so the fence check and the write it guards cannot
|
|
11
|
-
# be interleaved with anything. Postgres needs
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# be interleaved with anything. Postgres needs a row lock on the claim to get that,
|
|
12
|
+
# because there readers and writers run concurrently and a statement's snapshot can
|
|
13
|
+
# be stale; Redis needs a Lua script. Here the transaction *is* the exclusion.
|
|
14
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.
|
|
@@ -141,7 +141,15 @@ CREATE TABLE IF NOT EXISTS workflow_checkpoint (
|
|
|
141
141
|
CREATE TABLE IF NOT EXISTS workflow_claim (
|
|
142
142
|
workflow TEXT PRIMARY KEY,
|
|
143
143
|
token INTEGER NOT NULL,
|
|
144
|
-
|
|
144
|
+
-- The budget: the latest this claim can lapse at, whatever its holder does.
|
|
145
|
+
held_until REAL NOT NULL,
|
|
146
|
+
-- When it lapses if nothing more is heard, which is what `CLAIM` tests. Every write of
|
|
147
|
+
-- it is a `MIN` against `held_until`, so a sign of life cannot carry a pass past its
|
|
148
|
+
-- budget or take a workflow back after a `RELEASE`.
|
|
149
|
+
alive_until REAL NOT NULL,
|
|
150
|
+
-- What one sign of life is worth, carried here because `RECORD` is one and is not told:
|
|
151
|
+
-- the statement making a write has only the workflow to go on.
|
|
152
|
+
alive_for REAL NOT NULL
|
|
145
153
|
) WITHOUT ROWID;
|
|
146
154
|
|
|
147
155
|
CREATE TABLE IF NOT EXISTS workflow_queue (
|
|
@@ -165,18 +173,62 @@ CREATE INDEX IF NOT EXISTS workflow_queue_visible_at ON workflow_queue (namespac
|
|
|
165
173
|
# between the two. SQLite *is* the caller's machine, so that argument does not apply;
|
|
166
174
|
# keeping the clock in SQL anyway costs nothing and keeps the three stores reading alike.
|
|
167
175
|
CLAIM = """
|
|
168
|
-
INSERT INTO workflow_claim (workflow, token, held_until)
|
|
169
|
-
VALUES (
|
|
176
|
+
INSERT INTO workflow_claim (workflow, token, held_until, alive_until, alive_for)
|
|
177
|
+
VALUES (
|
|
178
|
+
:workflow, 1,
|
|
179
|
+
unixepoch('now', 'subsec') + :budget,
|
|
180
|
+
unixepoch('now', 'subsec') + MIN(:alive, :budget),
|
|
181
|
+
:alive
|
|
182
|
+
)
|
|
170
183
|
ON CONFLICT (workflow) DO UPDATE
|
|
171
|
-
SET token = workflow_claim.token + 1,
|
|
172
|
-
|
|
184
|
+
SET token = workflow_claim.token + 1,
|
|
185
|
+
held_until = unixepoch('now', 'subsec') + :budget,
|
|
186
|
+
alive_until = unixepoch('now', 'subsec') + MIN(:alive, :budget),
|
|
187
|
+
alive_for = :alive
|
|
188
|
+
WHERE workflow_claim.alive_until <= unixepoch('now', 'subsec')
|
|
173
189
|
RETURNING token
|
|
174
190
|
"""
|
|
175
191
|
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
192
|
+
# A fresh budget and a sign of life with it: what a step that declared a `within` spends
|
|
193
|
+
# before running its effect. Conditional on the token rather than on the deadline, since a
|
|
194
|
+
# claim that has lapsed without being taken is still this pass's to stretch. The `MAX`
|
|
195
|
+
# against the budget already standing is what keeps a short window from taking time away
|
|
196
|
+
# from the unannotated steps behind it: a budget can only ever be too generous from here.
|
|
197
|
+
EXTEND = """
|
|
198
|
+
UPDATE workflow_claim
|
|
199
|
+
SET held_until = MAX(held_until, unixepoch('now', 'subsec') + :budget),
|
|
200
|
+
alive_until = MIN(unixepoch('now', 'subsec') + :alive, MAX(held_until, unixepoch('now', 'subsec') + :budget)),
|
|
201
|
+
alive_for = :alive
|
|
202
|
+
WHERE workflow = :workflow AND token <= :token
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
# A sign of life and nothing else: the worker's tick. The `MIN` against the budget is what
|
|
206
|
+
# makes "this does not buy more time" true rather than merely intended, and refusing once
|
|
207
|
+
# the budget has run out is what makes the worker act on it: a renewal that reported
|
|
208
|
+
# success on a lapsed claim would keep a hung pass running for as long as nothing else
|
|
209
|
+
# happened to take its workflow.
|
|
210
|
+
RENEW = """
|
|
211
|
+
UPDATE workflow_claim
|
|
212
|
+
SET alive_until = MIN(unixepoch('now', 'subsec') + :alive, held_until), alive_for = :alive
|
|
213
|
+
WHERE workflow = :workflow AND token <= :token AND held_until > unixepoch('now', 'subsec')
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
# A write is a sign of life, so `record` and `transact` note one in the same transaction
|
|
217
|
+
# they write in. Postgres folds this into the fence CTE its `RECORD` already locks with;
|
|
218
|
+
# SQLite admits one writer at a time, so a second statement inside the same transaction is
|
|
219
|
+
# the same atomicity by a plainer route. Conditional on the token for the reason the
|
|
220
|
+
# Postgres CTE is: a write refused at the fence must renew nobody, or a superseded pass's
|
|
221
|
+
# stray writes would keep the winner's claim alive after the winner had died.
|
|
222
|
+
WROTE = """
|
|
223
|
+
UPDATE workflow_claim
|
|
224
|
+
SET alive_until = MIN(unixepoch('now', 'subsec') + alive_for, held_until)
|
|
225
|
+
WHERE workflow = :workflow AND token <= :token
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
# The fenced, conditional write, as one statement. The Postgres version locks the claim row
|
|
229
|
+
# in a data-modifying CTE so a claim committing mid-statement cannot go unseen; here the
|
|
230
|
+
# statement runs under `BEGIN IMMEDIATE` beside `WROTE` and SQLite admits one writer, so
|
|
231
|
+
# selecting the claim row inline is already serialized against every other write.
|
|
180
232
|
#
|
|
181
233
|
# `DO UPDATE SET value = the value already there` is a write that changes nothing and
|
|
182
234
|
# therefore returns the row that was already stored, which is how a caller that lost the
|
|
@@ -237,7 +289,15 @@ LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ? ORDER BY
|
|
|
237
289
|
HISTORY = "SELECT step, value, written_at FROM workflow_checkpoint WHERE workflow = ? ORDER BY seq"
|
|
238
290
|
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
239
291
|
# number up and a pass that comes back from the dead still loses.
|
|
240
|
-
|
|
292
|
+
# Both deadlines, and the budget is the load-bearing one: a write this pass had already
|
|
293
|
+
# started is entitled to land, since releasing keeps the token, and bringing `held_until`
|
|
294
|
+
# down to now is what stops that write's own `WROTE` from claiming the workflow straight
|
|
295
|
+
# back, since every renewal is a `MIN` against it.
|
|
296
|
+
RELEASE = """
|
|
297
|
+
UPDATE workflow_claim
|
|
298
|
+
SET held_until = unixepoch('now', 'subsec'), alive_until = unixepoch('now', 'subsec')
|
|
299
|
+
WHERE workflow = ? AND token = ?
|
|
300
|
+
"""
|
|
241
301
|
|
|
242
302
|
# Forget every record a workflow has. Paired with `SUPERSEDE` below and never run without
|
|
243
303
|
# it, which is what the transaction in `discard` is for.
|
|
@@ -253,7 +313,9 @@ DISCARD = "DELETE FROM workflow_checkpoint WHERE workflow = ?"
|
|
|
253
313
|
# ordering, not the claim.
|
|
254
314
|
SUPERSEDE = """
|
|
255
315
|
UPDATE workflow_claim
|
|
256
|
-
SET token = token + 1,
|
|
316
|
+
SET token = token + 1,
|
|
317
|
+
held_until = unixepoch('now', 'subsec'),
|
|
318
|
+
alive_until = unixepoch('now', 'subsec')
|
|
257
319
|
WHERE workflow = ?
|
|
258
320
|
"""
|
|
259
321
|
|
|
@@ -296,6 +358,19 @@ CANCEL = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ?"
|
|
|
296
358
|
# pass that runs sooner writes it again.
|
|
297
359
|
SUSPEND = "UPDATE workflow_queue SET visible_at = ? WHERE namespace = ? AND workflow = ? AND visible_at = ?"
|
|
298
360
|
|
|
361
|
+
# Keep a delivery this worker's for another `within`, under the same comparison as `SUSPEND`
|
|
362
|
+
# and for the same reason: a `make_ready` that landed since wrote a different `visible_at`,
|
|
363
|
+
# and pushing the visibility out on top of it would bury a wakeup that already arrived.
|
|
364
|
+
#
|
|
365
|
+
# The new visibility is returned because it *is* the new receipt, which is the price of the
|
|
366
|
+
# trick that makes this table a queue: a worker still holding the old one would find its own
|
|
367
|
+
# `FINISH` refused by that equality and the workflow redelivered for nothing.
|
|
368
|
+
RENEW_DELIVERY = """
|
|
369
|
+
UPDATE workflow_queue SET visible_at = unixepoch('now', 'subsec') + :within
|
|
370
|
+
WHERE namespace = :namespace AND workflow = :workflow AND visible_at = :receipt
|
|
371
|
+
RETURNING visible_at
|
|
372
|
+
"""
|
|
373
|
+
|
|
299
374
|
# What an effect is for a store whose datastore is a SQLite file: a callback handed a
|
|
300
375
|
# cursor already inside `transact`'s transaction.
|
|
301
376
|
#
|
|
@@ -509,30 +584,65 @@ class SqliteCheckpointer:
|
|
|
509
584
|
for step, encoded, written_at in rows
|
|
510
585
|
}
|
|
511
586
|
|
|
512
|
-
async def claim(self, workflow: str,
|
|
587
|
+
async def claim(self, workflow: str, budget: timedelta, alive: timedelta) -> Pass | None:
|
|
513
588
|
taken = await self.database.run(
|
|
514
589
|
lambda connection: connection.execute(
|
|
515
590
|
CLAIM,
|
|
516
|
-
{
|
|
591
|
+
{
|
|
592
|
+
"workflow": workflow,
|
|
593
|
+
"budget": budget.total_seconds(),
|
|
594
|
+
"alive": alive.total_seconds(),
|
|
595
|
+
},
|
|
517
596
|
).fetchone()
|
|
518
597
|
)
|
|
519
598
|
if taken is None:
|
|
520
599
|
return None
|
|
521
600
|
return Pass(workflow=workflow, token=int(taken[0]))
|
|
522
601
|
|
|
602
|
+
async def extend(self, holder: Pass, budget: timedelta, alive: timedelta) -> bool:
|
|
603
|
+
changed = await self.database.run(
|
|
604
|
+
lambda connection: (
|
|
605
|
+
connection.execute(
|
|
606
|
+
EXTEND,
|
|
607
|
+
{
|
|
608
|
+
"workflow": holder.workflow,
|
|
609
|
+
"token": holder.token,
|
|
610
|
+
"budget": budget.total_seconds(),
|
|
611
|
+
"alive": alive.total_seconds(),
|
|
612
|
+
},
|
|
613
|
+
).rowcount
|
|
614
|
+
)
|
|
615
|
+
)
|
|
616
|
+
# No row touched means the `WHERE` refused the token, which is the only way this
|
|
617
|
+
# misses: a `Pass` exists because a `claim` wrote the row it names.
|
|
618
|
+
return changed == 1
|
|
619
|
+
|
|
620
|
+
async def renew(self, holder: Pass, alive: timedelta) -> bool:
|
|
621
|
+
changed = await self.database.run(
|
|
622
|
+
lambda connection: (
|
|
623
|
+
connection.execute(
|
|
624
|
+
RENEW,
|
|
625
|
+
{"workflow": holder.workflow, "token": holder.token, "alive": alive.total_seconds()},
|
|
626
|
+
).rowcount
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
return changed == 1
|
|
630
|
+
|
|
523
631
|
async def record(self, holder: Pass, key: str, value: object) -> Recorded:
|
|
524
632
|
encoded = self.codec.encode(value)
|
|
525
|
-
|
|
526
|
-
|
|
633
|
+
|
|
634
|
+
# The encoding stored, and whether it is this call's, which SQLite answers with 0/1.
|
|
635
|
+
def write(cursor: sqlite3.Cursor) -> tuple[str, int] | None:
|
|
636
|
+
# The write and the sign of life it counts as, in one transaction, so a crash
|
|
637
|
+
# between them cannot leave a record whose writer looks dead.
|
|
638
|
+
stored = cursor.execute(
|
|
527
639
|
RECORD,
|
|
528
|
-
{
|
|
529
|
-
"workflow": holder.workflow,
|
|
530
|
-
"step": key,
|
|
531
|
-
"value": encoded,
|
|
532
|
-
"token": holder.token,
|
|
533
|
-
},
|
|
640
|
+
{"workflow": holder.workflow, "step": key, "value": encoded, "token": holder.token},
|
|
534
641
|
).fetchone()
|
|
535
|
-
|
|
642
|
+
cursor.execute(WROTE, {"workflow": holder.workflow, "token": holder.token})
|
|
643
|
+
return stored
|
|
644
|
+
|
|
645
|
+
stored = await self.database.run(lambda connection: transacted(connection, write))
|
|
536
646
|
if stored is None:
|
|
537
647
|
# The statement wrote nothing, which happens for exactly one reason: the
|
|
538
648
|
# `WHERE` that guards the insert compared this pass's token against the fence
|
|
@@ -554,6 +664,14 @@ class SqliteCheckpointer:
|
|
|
554
664
|
|
|
555
665
|
The effect's result is written and read back through the codec rather than
|
|
556
666
|
returned as it came, so it round-trips exactly as a later pass will see it.
|
|
667
|
+
|
|
668
|
+
What the write lock costs is stated rather than hidden: it is the connection's,
|
|
669
|
+
and there is one connection, so nothing else in this process reaches the store
|
|
670
|
+
until the effect returns, the worker's own renewal included. The claim is safe
|
|
671
|
+
regardless, since the sign of life at the end of the transaction lands before any
|
|
672
|
+
queued `claim` runs; what a long effect does lose is its delivery, which is not
|
|
673
|
+
renewed meanwhile and is redelivered after a `lease` to a pass that finds the
|
|
674
|
+
workflow held. Keep effects short, or accept that redelivery.
|
|
557
675
|
"""
|
|
558
676
|
|
|
559
677
|
def one_commit(cursor: sqlite3.Cursor) -> object:
|
|
@@ -565,6 +683,7 @@ class SqliteCheckpointer:
|
|
|
565
683
|
return self.codec.decode(recorded[0])
|
|
566
684
|
written = self.codec.encode(effect(cursor))
|
|
567
685
|
cursor.execute(WRITE, (holder.workflow, key, written))
|
|
686
|
+
cursor.execute(WROTE, {"workflow": holder.workflow, "token": holder.token})
|
|
568
687
|
return self.codec.decode(written)
|
|
569
688
|
|
|
570
689
|
return await self.database.run(lambda connection: transacted(connection, one_commit))
|
|
@@ -717,6 +836,34 @@ class SqliteScheduler:
|
|
|
717
836
|
"""Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
|
|
718
837
|
return None
|
|
719
838
|
|
|
839
|
+
async def extend(self, delivery: Delivery, within: timedelta) -> Delivery:
|
|
840
|
+
"""
|
|
841
|
+
Push this delivery's visibility out, and say what it is called now.
|
|
842
|
+
|
|
843
|
+
The new visibility is the new receipt, since this table's receipt *is* its
|
|
844
|
+
visibility, so the caller is handed a delivery to use from here rather than left to
|
|
845
|
+
discover that the one it holds has been renamed.
|
|
846
|
+
|
|
847
|
+
No row means this delivery is no longer this worker's: cancelled, or rescheduled by
|
|
848
|
+
a wakeup that arrived mid-pass, either of which wrote a `visible_at` that is not the
|
|
849
|
+
one it took. The answer to both is to hand back what came in, exactly as `wake_at`
|
|
850
|
+
and `done` already do.
|
|
851
|
+
"""
|
|
852
|
+
renewed = await self.database.run(
|
|
853
|
+
lambda connection: connection.execute(
|
|
854
|
+
RENEW_DELIVERY,
|
|
855
|
+
{
|
|
856
|
+
"namespace": self.namespace,
|
|
857
|
+
"workflow": delivery.workflow,
|
|
858
|
+
"receipt": float(delivery.receipt),
|
|
859
|
+
"within": within.total_seconds(),
|
|
860
|
+
},
|
|
861
|
+
).fetchone()
|
|
862
|
+
)
|
|
863
|
+
if renewed is None:
|
|
864
|
+
return delivery
|
|
865
|
+
return Delivery(workflow=delivery.workflow, receipt=repr(float(renewed[0])))
|
|
866
|
+
|
|
720
867
|
async def cancel(self, workflow: str) -> None:
|
|
721
868
|
"""
|
|
722
869
|
Drop the workflow's row, whichever of the three things its `visible_at` means.
|
|
File without changes
|
|
File without changes
|