without-durability-sqlite 0.0.7__tar.gz → 0.0.9__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.9}/PKG-INFO +5 -5
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.9}/README.md +2 -2
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.9}/pyproject.toml +3 -3
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.9}/pyproject.toml.orig +3 -3
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.9}/src/without_durability_sqlite/store.py +267 -24
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.9}/src/without_durability_sqlite/__init__.py +0 -0
- {without_durability_sqlite-0.0.7 → without_durability_sqlite-0.0.9}/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.9
|
|
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.9
|
|
18
|
+
Requires-Dist: without-durability==0.0.9
|
|
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.9"
|
|
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.9",
|
|
25
|
+
"without-durability==0.0.9",
|
|
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.9"
|
|
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.9",
|
|
28
|
+
"without-durability==0.0.9",
|
|
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.
|
|
@@ -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,13 +134,22 @@ 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
|
|
|
131
141
|
CREATE TABLE IF NOT EXISTS workflow_claim (
|
|
132
142
|
workflow TEXT PRIMARY KEY,
|
|
133
143
|
token INTEGER NOT NULL,
|
|
134
|
-
|
|
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
|
|
135
153
|
) WITHOUT ROWID;
|
|
136
154
|
|
|
137
155
|
CREATE TABLE IF NOT EXISTS workflow_queue (
|
|
@@ -155,18 +173,62 @@ CREATE INDEX IF NOT EXISTS workflow_queue_visible_at ON workflow_queue (namespac
|
|
|
155
173
|
# between the two. SQLite *is* the caller's machine, so that argument does not apply;
|
|
156
174
|
# keeping the clock in SQL anyway costs nothing and keeps the three stores reading alike.
|
|
157
175
|
CLAIM = """
|
|
158
|
-
INSERT INTO workflow_claim (workflow, token, held_until)
|
|
159
|
-
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
|
+
)
|
|
160
183
|
ON CONFLICT (workflow) DO UPDATE
|
|
161
|
-
SET token = workflow_claim.token + 1,
|
|
162
|
-
|
|
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')
|
|
163
189
|
RETURNING token
|
|
164
190
|
"""
|
|
165
191
|
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
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.
|
|
170
232
|
#
|
|
171
233
|
# `DO UPDATE SET value = the value already there` is a write that changes nothing and
|
|
172
234
|
# therefore returns the row that was already stored, which is how a caller that lost the
|
|
@@ -224,9 +286,38 @@ WRITE = "INSERT INTO workflow_checkpoint (workflow, step, value) VALUES (?, ?, ?
|
|
|
224
286
|
# than a formality: `WHERE workflow = ?` is served by the unique index on
|
|
225
287
|
# `(workflow, step)`, so without it the rows come back sorted by step name.
|
|
226
288
|
LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ? ORDER BY seq"
|
|
289
|
+
HISTORY = "SELECT step, value, written_at FROM workflow_checkpoint WHERE workflow = ? ORDER BY seq"
|
|
227
290
|
# Hand the workflow back early, but keep the token, so the next claim gets the next
|
|
228
291
|
# number up and a pass that comes back from the dead still loses.
|
|
229
|
-
|
|
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
|
+
"""
|
|
301
|
+
|
|
302
|
+
# Forget every record a workflow has. Paired with `SUPERSEDE` below and never run without
|
|
303
|
+
# it, which is what the transaction in `discard` is for.
|
|
304
|
+
DISCARD = "DELETE FROM workflow_checkpoint WHERE workflow = ?"
|
|
305
|
+
|
|
306
|
+
# Take the fencing token *up*, so a pass still holding one is refused at its next write.
|
|
307
|
+
#
|
|
308
|
+
# An `UPDATE` rather than the upsert `CLAIM` is, and the difference is what it declines to
|
|
309
|
+
# do: a workflow with no claim row has no `Pass` outstanding, since a `Pass` is only ever
|
|
310
|
+
# handed out by a `claim` that wrote one, so there is nothing to fence and a row minted
|
|
311
|
+
# here would be a tombstone for a workflow nobody ever claimed. The deadline is moved to
|
|
312
|
+
# now at the same time, so the workflow is claimable again immediately: what is kept is the
|
|
313
|
+
# ordering, not the claim.
|
|
314
|
+
SUPERSEDE = """
|
|
315
|
+
UPDATE workflow_claim
|
|
316
|
+
SET token = token + 1,
|
|
317
|
+
held_until = unixepoch('now', 'subsec'),
|
|
318
|
+
alive_until = unixepoch('now', 'subsec')
|
|
319
|
+
WHERE workflow = ?
|
|
320
|
+
"""
|
|
230
321
|
|
|
231
322
|
# Take the oldest visible workflow and push it a lease into the future. There is no
|
|
232
323
|
# `SKIP LOCKED` here and none is wanted: it exists so one poller does not queue behind
|
|
@@ -255,6 +346,11 @@ ON CONFLICT (namespace, workflow) DO UPDATE SET visible_at = excluded.visible_at
|
|
|
255
346
|
# different `visible_at`, so the equality is the whole check.
|
|
256
347
|
FINISH = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ? AND visible_at = ?"
|
|
257
348
|
|
|
349
|
+
# Withdraw the workflow's right to run, whatever its row currently means. Unconditional
|
|
350
|
+
# where `FINISH` compares the receipt, which is the difference between finishing a pass
|
|
351
|
+
# (leave anything that asked for another) and cancelling the workflow (leave nothing).
|
|
352
|
+
CANCEL = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ?"
|
|
353
|
+
|
|
258
354
|
# Suspend until a deadline, under the same comparison and for the same reason. A workflow
|
|
259
355
|
# holds one row here, so writing the deadline unconditionally would land on top of a
|
|
260
356
|
# `make_ready` that arrived while the pass was ending and push a confirmation out to a
|
|
@@ -262,6 +358,19 @@ FINISH = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ? AND vi
|
|
|
262
358
|
# pass that runs sooner writes it again.
|
|
263
359
|
SUSPEND = "UPDATE workflow_queue SET visible_at = ? WHERE namespace = ? AND workflow = ? AND visible_at = ?"
|
|
264
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
|
+
|
|
265
374
|
# What an effect is for a store whose datastore is a SQLite file: a callback handed a
|
|
266
375
|
# cursor already inside `transact`'s transaction.
|
|
267
376
|
#
|
|
@@ -467,30 +576,73 @@ class SqliteCheckpointer:
|
|
|
467
576
|
rows = await self.database.run(lambda connection: connection.execute(LOAD, (workflow,)).fetchall())
|
|
468
577
|
return {step: self.codec.decode(encoded) for step, encoded in rows}
|
|
469
578
|
|
|
470
|
-
async def
|
|
579
|
+
async def history(self, workflow: str) -> dict[str, Written]:
|
|
580
|
+
"""The same records `load` returns, each with the moment it was written."""
|
|
581
|
+
rows = await self.database.run(lambda connection: connection.execute(HISTORY, (workflow,)).fetchall())
|
|
582
|
+
return {
|
|
583
|
+
step: Written(value=self.codec.decode(encoded), at=datetime.fromtimestamp(written_at, UTC))
|
|
584
|
+
for step, encoded, written_at in rows
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async def claim(self, workflow: str, budget: timedelta, alive: timedelta) -> Pass | None:
|
|
471
588
|
taken = await self.database.run(
|
|
472
589
|
lambda connection: connection.execute(
|
|
473
590
|
CLAIM,
|
|
474
|
-
{
|
|
591
|
+
{
|
|
592
|
+
"workflow": workflow,
|
|
593
|
+
"budget": budget.total_seconds(),
|
|
594
|
+
"alive": alive.total_seconds(),
|
|
595
|
+
},
|
|
475
596
|
).fetchone()
|
|
476
597
|
)
|
|
477
598
|
if taken is None:
|
|
478
599
|
return None
|
|
479
600
|
return Pass(workflow=workflow, token=int(taken[0]))
|
|
480
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
|
+
|
|
481
631
|
async def record(self, holder: Pass, key: str, value: object) -> Recorded:
|
|
482
632
|
encoded = self.codec.encode(value)
|
|
483
|
-
|
|
484
|
-
|
|
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(
|
|
485
639
|
RECORD,
|
|
486
|
-
{
|
|
487
|
-
"workflow": holder.workflow,
|
|
488
|
-
"step": key,
|
|
489
|
-
"value": encoded,
|
|
490
|
-
"token": holder.token,
|
|
491
|
-
},
|
|
640
|
+
{"workflow": holder.workflow, "step": key, "value": encoded, "token": holder.token},
|
|
492
641
|
).fetchone()
|
|
493
|
-
|
|
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))
|
|
494
646
|
if stored is None:
|
|
495
647
|
# The statement wrote nothing, which happens for exactly one reason: the
|
|
496
648
|
# `WHERE` that guards the insert compared this pass's token against the fence
|
|
@@ -512,6 +664,14 @@ class SqliteCheckpointer:
|
|
|
512
664
|
|
|
513
665
|
The effect's result is written and read back through the codec rather than
|
|
514
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.
|
|
515
675
|
"""
|
|
516
676
|
|
|
517
677
|
def one_commit(cursor: sqlite3.Cursor) -> object:
|
|
@@ -523,6 +683,7 @@ class SqliteCheckpointer:
|
|
|
523
683
|
return self.codec.decode(recorded[0])
|
|
524
684
|
written = self.codec.encode(effect(cursor))
|
|
525
685
|
cursor.execute(WRITE, (holder.workflow, key, written))
|
|
686
|
+
cursor.execute(WROTE, {"workflow": holder.workflow, "token": holder.token})
|
|
526
687
|
return self.codec.decode(written)
|
|
527
688
|
|
|
528
689
|
return await self.database.run(lambda connection: transacted(connection, one_commit))
|
|
@@ -547,6 +708,26 @@ class SqliteCheckpointer:
|
|
|
547
708
|
key, encoded = cast(tuple[str, str], stored)
|
|
548
709
|
return Entry(key=key, value=self.codec.decode(encoded))
|
|
549
710
|
|
|
711
|
+
async def discard(self, workflow: str) -> int:
|
|
712
|
+
"""
|
|
713
|
+
Forget every record this workflow has, and raise its fence, in one transaction.
|
|
714
|
+
|
|
715
|
+
One commit rather than two statements, because the two are only right together: a
|
|
716
|
+
crash between them either leaves the records deleted with the fence unraised, so
|
|
717
|
+
the pass that was mid-flight writes them back one at a time, or the reverse, which
|
|
718
|
+
fences a live pass for a deletion that never happened.
|
|
719
|
+
|
|
720
|
+
What is left behind is one claim row carrying a number. Nothing here sweeps it, in
|
|
721
|
+
keeping with the rest of this store, where nothing expires and tidying the file is
|
|
722
|
+
the deployment's homework.
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
def one_commit(cursor: sqlite3.Cursor) -> int:
|
|
726
|
+
cursor.execute(SUPERSEDE, (workflow,))
|
|
727
|
+
return cursor.execute(DISCARD, (workflow,)).rowcount
|
|
728
|
+
|
|
729
|
+
return await self.database.run(lambda connection: transacted(connection, one_commit))
|
|
730
|
+
|
|
550
731
|
async def release(self, holder: Pass) -> None:
|
|
551
732
|
await self.database.run(lambda connection: connection.execute(RELEASE, (holder.workflow, holder.token)))
|
|
552
733
|
|
|
@@ -655,6 +836,50 @@ class SqliteScheduler:
|
|
|
655
836
|
"""Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
|
|
656
837
|
return None
|
|
657
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
|
+
|
|
867
|
+
async def cancel(self, workflow: str) -> None:
|
|
868
|
+
"""
|
|
869
|
+
Drop the workflow's row, whichever of the three things its `visible_at` means.
|
|
870
|
+
|
|
871
|
+
One `DELETE` covers queued, sleeping, and out with a worker, because this table
|
|
872
|
+
holds one row per workflow and the visibility is the only thing that differs
|
|
873
|
+
between them. That is the same collapse that leaves `wake_due` and `reclaim` with
|
|
874
|
+
nothing to do.
|
|
875
|
+
|
|
876
|
+
The half of `cancel` a queue sweep cannot reach comes free with it: a pass still in
|
|
877
|
+
flight answers with `wake_at`, which is an `UPDATE` conditional on the visibility
|
|
878
|
+
still being the one it took, and a deleted row has none. So the deadline it was
|
|
879
|
+
about to write updates nothing and a deleted workflow is not put back to sleep.
|
|
880
|
+
"""
|
|
881
|
+
await self.database.run(lambda connection: connection.execute(CANCEL, (self.namespace, workflow)))
|
|
882
|
+
|
|
658
883
|
async def done(self, delivery: Delivery) -> None:
|
|
659
884
|
"""
|
|
660
885
|
Drop the workflow, unless something asked for another pass while this one ran.
|
|
@@ -724,3 +949,21 @@ class SqliteDurable:
|
|
|
724
949
|
return Entry(key=key, value=self.checkpointer.codec.decode(encoded))
|
|
725
950
|
|
|
726
951
|
return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
|
|
952
|
+
|
|
953
|
+
async def delete(self, workflow: str) -> int:
|
|
954
|
+
"""
|
|
955
|
+
Cancel the workflow's wakeups and forget its records, together or not at all.
|
|
956
|
+
|
|
957
|
+
Three statements in one commit, so the ordering `SplitDurable` has to reason about
|
|
958
|
+
does not arise: there is no window in which the records are gone and a wakeup is
|
|
959
|
+
not, and none in which the fence has been raised for a deletion that did not
|
|
960
|
+
happen. Which is the same thing `arrive` gets from this store and for the same
|
|
961
|
+
reason, one file.
|
|
962
|
+
"""
|
|
963
|
+
|
|
964
|
+
def one_commit(cursor: sqlite3.Cursor) -> int:
|
|
965
|
+
cursor.execute(CANCEL, (self.scheduler.namespace, workflow))
|
|
966
|
+
cursor.execute(SUPERSEDE, (workflow,))
|
|
967
|
+
return cursor.execute(DISCARD, (workflow,)).rowcount
|
|
968
|
+
|
|
969
|
+
return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
|
|
File without changes
|
|
File without changes
|