without-durability-sqlite 0.0.7__tar.gz → 0.0.8__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.7 → without_durability_sqlite-0.0.8}/PKG-INFO +3 -3
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.8}/pyproject.toml +3 -3
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.8}/pyproject.toml.orig +3 -3
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.8}/src/without_durability_sqlite/store.py +96 -0
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.8}/README.md +0 -0
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.8}/src/without_durability_sqlite/__init__.py +0 -0
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.8}/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.8
|
|
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.8
|
|
18
|
+
Requires-Dist: without-durability==0.0.8
|
|
19
19
|
Requires-Python: >=3.14
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
|
|
@@ -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.8"
|
|
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.8",
|
|
25
|
+
"without-durability==0.0.8",
|
|
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.8"
|
|
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.8",
|
|
28
|
+
"without-durability==0.0.8",
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
[tool.uv.sources]
|
|
@@ -59,6 +59,7 @@ from collections.abc import Callable
|
|
|
59
59
|
from contextlib import closing
|
|
60
60
|
from dataclasses import dataclass
|
|
61
61
|
from dataclasses import field
|
|
62
|
+
from datetime import UTC
|
|
62
63
|
from datetime import datetime
|
|
63
64
|
from datetime import timedelta
|
|
64
65
|
from pathlib import Path
|
|
@@ -76,6 +77,7 @@ from without_durability.interfaces import Entry
|
|
|
76
77
|
from without_durability.interfaces import Fenced
|
|
77
78
|
from without_durability.interfaces import Pass
|
|
78
79
|
from without_durability.interfaces import Recorded
|
|
80
|
+
from without_durability.interfaces import Written
|
|
79
81
|
from without_durability.interfaces import check_duration
|
|
80
82
|
from without_durability.stepwise import now_utc
|
|
81
83
|
|
|
@@ -112,6 +114,13 @@ BUSY_TIMEOUT = Milliseconds(5_000)
|
|
|
112
114
|
# invites any tool to open, so the guarantee would otherwise rest on a promise SQLite
|
|
113
115
|
# declines to make. Declaring the column is what puts this table outside that sentence.
|
|
114
116
|
#
|
|
117
|
+
# `written_at` is what `history` reads, and its `DEFAULT` does the same work `seq` does:
|
|
118
|
+
# evaluated on insert, and left alone by every conflict update below, so a losing write
|
|
119
|
+
# moves the value, the position, and the time equally not at all. The clock is SQL's for
|
|
120
|
+
# the reason the claim's is, which here is consistency with the other two stores rather
|
|
121
|
+
# than a guarantee: SQLite *is* the caller's machine, so there are no two clocks to
|
|
122
|
+
# disagree.
|
|
123
|
+
#
|
|
115
124
|
# `(workflow, step)` is `UNIQUE` rather than the primary key because a table gets one
|
|
116
125
|
# primary key and `seq` is now it. Nothing else changes: both columns are `NOT NULL`, so
|
|
117
126
|
# the constraint admits exactly the rows the primary key did, and it is still what the
|
|
@@ -125,6 +134,7 @@ CREATE TABLE IF NOT EXISTS workflow_checkpoint (
|
|
|
125
134
|
workflow TEXT NOT NULL,
|
|
126
135
|
step TEXT NOT NULL,
|
|
127
136
|
value TEXT NOT NULL,
|
|
137
|
+
written_at REAL NOT NULL DEFAULT (unixepoch('now', 'subsec')),
|
|
128
138
|
UNIQUE (workflow, step)
|
|
129
139
|
);
|
|
130
140
|
|
|
@@ -224,10 +234,29 @@ WRITE = "INSERT INTO workflow_checkpoint (workflow, step, value) VALUES (?, ?, ?
|
|
|
224
234
|
# than a formality: `WHERE workflow = ?` is served by the unique index on
|
|
225
235
|
# `(workflow, step)`, so without it the rows come back sorted by step name.
|
|
226
236
|
LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ? ORDER BY seq"
|
|
237
|
+
HISTORY = "SELECT step, value, written_at FROM workflow_checkpoint WHERE workflow = ? ORDER BY seq"
|
|
227
238
|
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
228
239
|
# number up and a pass that comes back from the dead still loses.
|
|
229
240
|
RELEASE = "UPDATE workflow_claim SET held_until = unixepoch('now', 'subsec') WHERE workflow = ? AND token = ?"
|
|
230
241
|
|
|
242
|
+
# Forget every record a workflow has. Paired with `SUPERSEDE` below and never run without
|
|
243
|
+
# it, which is what the transaction in `discard` is for.
|
|
244
|
+
DISCARD = "DELETE FROM workflow_checkpoint WHERE workflow = ?"
|
|
245
|
+
|
|
246
|
+
# Take the fencing token *up*, so a pass still holding one is refused at its next write.
|
|
247
|
+
#
|
|
248
|
+
# An `UPDATE` rather than the upsert `CLAIM` is, and the difference is what it declines to
|
|
249
|
+
# do: a workflow with no claim row has no `Pass` outstanding, since a `Pass` is only ever
|
|
250
|
+
# handed out by a `claim` that wrote one, so there is nothing to fence and a row minted
|
|
251
|
+
# here would be a tombstone for a workflow nobody ever claimed. The deadline is moved to
|
|
252
|
+
# now at the same time, so the workflow is claimable again immediately: what is kept is the
|
|
253
|
+
# ordering, not the claim.
|
|
254
|
+
SUPERSEDE = """
|
|
255
|
+
UPDATE workflow_claim
|
|
256
|
+
SET token = token + 1, held_until = unixepoch('now', 'subsec')
|
|
257
|
+
WHERE workflow = ?
|
|
258
|
+
"""
|
|
259
|
+
|
|
231
260
|
# Take the oldest visible workflow and push it a lease into the future. There is no
|
|
232
261
|
# `SKIP LOCKED` here and none is wanted: it exists so one poller does not queue behind
|
|
233
262
|
# another's row lock, and SQLite has no concurrent writers to step over.
|
|
@@ -255,6 +284,11 @@ ON CONFLICT (namespace, workflow) DO UPDATE SET visible_at = excluded.visible_at
|
|
|
255
284
|
# different `visible_at`, so the equality is the whole check.
|
|
256
285
|
FINISH = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ? AND visible_at = ?"
|
|
257
286
|
|
|
287
|
+
# Withdraw the workflow's right to run, whatever its row currently means. Unconditional
|
|
288
|
+
# where `FINISH` compares the receipt, which is the difference between finishing a pass
|
|
289
|
+
# (leave anything that asked for another) and cancelling the workflow (leave nothing).
|
|
290
|
+
CANCEL = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ?"
|
|
291
|
+
|
|
258
292
|
# Suspend until a deadline, under the same comparison and for the same reason. A workflow
|
|
259
293
|
# holds one row here, so writing the deadline unconditionally would land on top of a
|
|
260
294
|
# `make_ready` that arrived while the pass was ending and push a confirmation out to a
|
|
@@ -467,6 +501,14 @@ class SqliteCheckpointer:
|
|
|
467
501
|
rows = await self.database.run(lambda connection: connection.execute(LOAD, (workflow,)).fetchall())
|
|
468
502
|
return {step: self.codec.decode(encoded) for step, encoded in rows}
|
|
469
503
|
|
|
504
|
+
async def history(self, workflow: str) -> dict[str, Written]:
|
|
505
|
+
"""The same records `load` returns, each with the moment it was written."""
|
|
506
|
+
rows = await self.database.run(lambda connection: connection.execute(HISTORY, (workflow,)).fetchall())
|
|
507
|
+
return {
|
|
508
|
+
step: Written(value=self.codec.decode(encoded), at=datetime.fromtimestamp(written_at, UTC))
|
|
509
|
+
for step, encoded, written_at in rows
|
|
510
|
+
}
|
|
511
|
+
|
|
470
512
|
async def claim(self, workflow: str, lease: timedelta) -> Pass | None:
|
|
471
513
|
taken = await self.database.run(
|
|
472
514
|
lambda connection: connection.execute(
|
|
@@ -547,6 +589,26 @@ class SqliteCheckpointer:
|
|
|
547
589
|
key, encoded = cast(tuple[str, str], stored)
|
|
548
590
|
return Entry(key=key, value=self.codec.decode(encoded))
|
|
549
591
|
|
|
592
|
+
async def discard(self, workflow: str) -> int:
|
|
593
|
+
"""
|
|
594
|
+
Forget every record this workflow has, and raise its fence, in one transaction.
|
|
595
|
+
|
|
596
|
+
One commit rather than two statements, because the two are only right together: a
|
|
597
|
+
crash between them either leaves the records deleted with the fence unraised, so
|
|
598
|
+
the pass that was mid-flight writes them back one at a time, or the reverse, which
|
|
599
|
+
fences a live pass for a deletion that never happened.
|
|
600
|
+
|
|
601
|
+
What is left behind is one claim row carrying a number. Nothing here sweeps it, in
|
|
602
|
+
keeping with the rest of this store, where nothing expires and tidying the file is
|
|
603
|
+
the deployment's homework.
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
def one_commit(cursor: sqlite3.Cursor) -> int:
|
|
607
|
+
cursor.execute(SUPERSEDE, (workflow,))
|
|
608
|
+
return cursor.execute(DISCARD, (workflow,)).rowcount
|
|
609
|
+
|
|
610
|
+
return await self.database.run(lambda connection: transacted(connection, one_commit))
|
|
611
|
+
|
|
550
612
|
async def release(self, holder: Pass) -> None:
|
|
551
613
|
await self.database.run(lambda connection: connection.execute(RELEASE, (holder.workflow, holder.token)))
|
|
552
614
|
|
|
@@ -655,6 +717,22 @@ class SqliteScheduler:
|
|
|
655
717
|
"""Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
|
|
656
718
|
return None
|
|
657
719
|
|
|
720
|
+
async def cancel(self, workflow: str) -> None:
|
|
721
|
+
"""
|
|
722
|
+
Drop the workflow's row, whichever of the three things its `visible_at` means.
|
|
723
|
+
|
|
724
|
+
One `DELETE` covers queued, sleeping, and out with a worker, because this table
|
|
725
|
+
holds one row per workflow and the visibility is the only thing that differs
|
|
726
|
+
between them. That is the same collapse that leaves `wake_due` and `reclaim` with
|
|
727
|
+
nothing to do.
|
|
728
|
+
|
|
729
|
+
The half of `cancel` a queue sweep cannot reach comes free with it: a pass still in
|
|
730
|
+
flight answers with `wake_at`, which is an `UPDATE` conditional on the visibility
|
|
731
|
+
still being the one it took, and a deleted row has none. So the deadline it was
|
|
732
|
+
about to write updates nothing and a deleted workflow is not put back to sleep.
|
|
733
|
+
"""
|
|
734
|
+
await self.database.run(lambda connection: connection.execute(CANCEL, (self.namespace, workflow)))
|
|
735
|
+
|
|
658
736
|
async def done(self, delivery: Delivery) -> None:
|
|
659
737
|
"""
|
|
660
738
|
Drop the workflow, unless something asked for another pass while this one ran.
|
|
@@ -724,3 +802,21 @@ class SqliteDurable:
|
|
|
724
802
|
return Entry(key=key, value=self.checkpointer.codec.decode(encoded))
|
|
725
803
|
|
|
726
804
|
return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
|
|
805
|
+
|
|
806
|
+
async def delete(self, workflow: str) -> int:
|
|
807
|
+
"""
|
|
808
|
+
Cancel the workflow's wakeups and forget its records, together or not at all.
|
|
809
|
+
|
|
810
|
+
Three statements in one commit, so the ordering `SplitDurable` has to reason about
|
|
811
|
+
does not arise: there is no window in which the records are gone and a wakeup is
|
|
812
|
+
not, and none in which the fence has been raised for a deletion that did not
|
|
813
|
+
happen. Which is the same thing `arrive` gets from this store and for the same
|
|
814
|
+
reason, one file.
|
|
815
|
+
"""
|
|
816
|
+
|
|
817
|
+
def one_commit(cursor: sqlite3.Cursor) -> int:
|
|
818
|
+
cursor.execute(CANCEL, (self.scheduler.namespace, workflow))
|
|
819
|
+
cursor.execute(SUPERSEDE, (workflow,))
|
|
820
|
+
return cursor.execute(DISCARD, (workflow,)).rowcount
|
|
821
|
+
|
|
822
|
+
return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|