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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: without-durability-sqlite
3
- Version: 0.0.7
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.7
18
- Requires-Dist: without-durability==0.0.7
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 `FOR UPDATE` on the claim row to get that
46
- and Redis needs a Lua script, while here the transaction *is* the exclusion. And
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 `FOR UPDATE` on the claim row to get that
25
- and Redis needs a Lua script, while here the transaction *is* the exclusion. And
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"
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.7",
25
- "without-durability==0.0.7",
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"
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.7",
28
- "without-durability==0.0.7",
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 `FOR UPDATE` on the claim row to get
12
- # that, because there readers and writers run concurrently and a statement's snapshot
13
- # can be stale; Redis needs a Lua script. Here the transaction *is* the exclusion.
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
- held_until REAL NOT NULL
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 (:workflow, 1, unixepoch('now', 'subsec') + :lease)
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, held_until = unixepoch('now', 'subsec') + :lease
162
- WHERE workflow_claim.held_until <= unixepoch('now', 'subsec')
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
- # The fenced, conditional write, as one statement. The Postgres version wraps its fence
167
- # read in a `FOR UPDATE` CTE so a claim committing mid-statement cannot go unseen; here
168
- # the statement is its own transaction and SQLite admits one writer, so selecting the
169
- # claim row inline is already serialized against every other write.
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
- RELEASE = "UPDATE workflow_claim SET held_until = unixepoch('now', 'subsec') WHERE workflow = ? AND token = ?"
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 claim(self, workflow: str, lease: timedelta) -> Pass | None:
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
- {"workflow": workflow, "lease": lease.total_seconds()},
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
- stored = await self.database.run(
484
- lambda connection: connection.execute(
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))