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.
@@ -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.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.7
18
- Requires-Dist: without-durability==0.0.7
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"
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.7",
25
- "without-durability==0.0.7",
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"
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.7",
28
- "without-durability==0.0.7",
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))