without-durability-sqlite 0.0.6__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.6
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.6
18
- Requires-Dist: without-durability==0.0.6
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
 
@@ -44,8 +44,8 @@ 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
45
  cannot be interleaved: Postgres needs `FOR UPDATE` on the claim row to get that
46
46
  and Redis needs a Lua script, while here the transaction *is* the exclusion. And
47
- there is nothing to co-locate, because the datastore is a file, so `transact` and
48
- `arrive` reach every table an application keeps in it. That last one is the same
47
+ there is nothing to co-locate, because the datastore is a file, so `transact`,
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.
50
50
 
51
51
  What it costs is the shape of the whole thing: one machine. Every process sharing
@@ -23,8 +23,8 @@ 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
24
  cannot be interleaved: Postgres needs `FOR UPDATE` on the claim row to get that
25
25
  and Redis needs a Lua script, while here the transaction *is* the exclusion. And
26
- there is nothing to co-locate, because the datastore is a file, so `transact` and
27
- `arrive` reach every table an application keeps in it. That last one is the same
26
+ there is nothing to co-locate, because the datastore is a file, so `transact`,
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.
29
29
 
30
30
  What it costs is the shape of the whole thing: one machine. Every process sharing
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-durability-sqlite"
7
- version = "0.0.6"
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.6",
25
- "without-durability==0.0.6",
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.6"
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.6",
28
- "without-durability==0.0.6",
27
+ "without-async==0.0.8",
28
+ "without-durability==0.0.8",
29
29
  ]
30
30
 
31
31
  [tool.uv.sources]
@@ -11,7 +11,7 @@
11
11
  # be interleaved with anything. Postgres needs `FOR UPDATE` on the claim row to get
12
12
  # that, because there readers and writers run concurrently and a statement's snapshot
13
13
  # can be stale; Redis needs a Lua script. Here the transaction *is* the exclusion.
14
- # - There is nothing to co-locate. `transact` and `arrive` reach the whole datastore
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.
17
17
  #
@@ -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
@@ -68,11 +69,15 @@ from typing import cast
68
69
  from without_async import Milliseconds
69
70
  from without_durability.codec import JSON
70
71
  from without_durability.codec import CheckpointCodec
72
+ from without_durability.interfaces import INBOX
73
+ from without_durability.interfaces import INBOX_DIGITS
71
74
  from without_durability.interfaces import LEASE
72
75
  from without_durability.interfaces import Delivery
76
+ from without_durability.interfaces import Entry
73
77
  from without_durability.interfaces import Fenced
74
78
  from without_durability.interfaces import Pass
75
79
  from without_durability.interfaces import Recorded
80
+ from without_durability.interfaces import Written
76
81
  from without_durability.interfaces import check_duration
77
82
  from without_durability.stepwise import now_utc
78
83
 
@@ -89,19 +94,49 @@ BUSY_TIMEOUT = Milliseconds(5_000)
89
94
  # `value` is TEXT rather than a richer type, which is the same shape the Redis store's
90
95
  # hash field has and leaves the same question open: what goes *in* the text is the
91
96
  # store's injected `CheckpointCodec`, defaulting to JSON because that is what makes a
92
- # checkpoint readable by anything that can open the file. `WITHOUT ROWID` because every
93
- # one of these tables is addressed by its primary key and never by a rowid, so the extra
94
- # indirection would be pure overhead.
97
+ # checkpoint readable by anything that can open the file.
95
98
  #
96
99
  # `NOT NULL` on `value` keeps "no row" and "a row holding JSON null" distinguishable, so
97
100
  # a step that legitimately records `None` is not read back as a step that never ran.
101
+ #
102
+ # `seq` is what `load`'s ordering guarantee rests on, and it is the same column Postgres
103
+ # names: assigned on insert, in insertion order, and left alone by the conflict update
104
+ # below, which is exactly the property the guarantee needs. The writer that first recorded
105
+ # a step decides where it sits, and a later write that loses moves neither the value nor
106
+ # the position. Nothing else here is a candidate, since the only other order on offer is
107
+ # the unique index's, which is step name: a different question with a plausible enough
108
+ # answer to pass a careless test.
109
+ #
110
+ # `INTEGER PRIMARY KEY` makes it an alias for the rowid rather than a column beside one,
111
+ # so it costs no storage and needs no sequence. Naming it is not decoration either. SQLite
112
+ # reserves the right to renumber rowids in "any tables that do not have an explicit INTEGER
113
+ # PRIMARY KEY" when a database is `VACUUM`ed, and this store hands an operator a file it
114
+ # invites any tool to open, so the guarantee would otherwise rest on a promise SQLite
115
+ # declines to make. Declaring the column is what puts this table outside that sentence.
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
+ #
124
+ # `(workflow, step)` is `UNIQUE` rather than the primary key because a table gets one
125
+ # primary key and `seq` is now it. Nothing else changes: both columns are `NOT NULL`, so
126
+ # the constraint admits exactly the rows the primary key did, and it is still what the
127
+ # upserts below name as their conflict target.
128
+ #
129
+ # The claim and queue tables stay `WITHOUT ROWID` because each is addressed by its primary
130
+ # key and never by a position, so for those the indirection would be pure overhead.
98
131
  SCHEMA = """
99
132
  CREATE TABLE IF NOT EXISTS workflow_checkpoint (
133
+ seq INTEGER PRIMARY KEY,
100
134
  workflow TEXT NOT NULL,
101
135
  step TEXT NOT NULL,
102
136
  value TEXT NOT NULL,
103
- PRIMARY KEY (workflow, step)
104
- ) WITHOUT ROWID;
137
+ written_at REAL NOT NULL DEFAULT (unixepoch('now', 'subsec')),
138
+ UNIQUE (workflow, step)
139
+ );
105
140
 
106
141
  CREATE TABLE IF NOT EXISTS workflow_claim (
107
142
  workflow TEXT PRIMARY KEY,
@@ -169,14 +204,59 @@ ON CONFLICT (workflow, step) DO UPDATE SET value = workflow_checkpoint.value
169
204
  RETURNING value
170
205
  """
171
206
 
207
+ # `supply` under a key this statement mints instead of one the caller brought: the append
208
+ # that puts a message in a workflow's inbox.
209
+ #
210
+ # The number is the highest `seq` in the table plus one, which is global rather than per
211
+ # workflow and so leaves gaps in any one workflow's run of keys. That is what the contract
212
+ # allows and what makes this a single statement: a per-workflow count would be a second
213
+ # read, and reading a maximum and then inserting against it is the exact race that would
214
+ # need a transaction to close. There are no concurrent writers here to lose it to, since
215
+ # SQLite admits one, but the store that has them (Postgres) reaches for the same shape and
216
+ # the two are easier to hold in one head this way.
217
+ #
218
+ # There is no `ON CONFLICT` clause and deliberately none: the key is fresh by construction,
219
+ # so a collision means the numbering is broken and a `UNIQUE` violation is the loud version
220
+ # of that. An upsert here would quietly hand back somebody else's message instead.
221
+ APPEND = f"""
222
+ INSERT INTO workflow_checkpoint (workflow, step, value)
223
+ SELECT
224
+ :workflow,
225
+ '{INBOX}' || printf('%0{INBOX_DIGITS}d', COALESCE((SELECT MAX(seq) FROM workflow_checkpoint), 0) + 1),
226
+ :value
227
+ RETURNING step, value
228
+ """
229
+
172
230
  FENCE = "SELECT token FROM workflow_claim WHERE workflow = ?"
173
231
  ALREADY = "SELECT value FROM workflow_checkpoint WHERE workflow = ? AND step = ?"
174
232
  WRITE = "INSERT INTO workflow_checkpoint (workflow, step, value) VALUES (?, ?, ?)"
175
- LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ?"
233
+ # `ORDER BY seq` is the whole of the ordering guarantee, and it is load-bearing rather
234
+ # than a formality: `WHERE workflow = ?` is served by the unique index on
235
+ # `(workflow, step)`, so without it the rows come back sorted by step name.
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"
176
238
  # Hand the workflow back early, but keep the token, so the next claim gets the next
177
239
  # number up and a pass that comes back from the dead still loses.
178
240
  RELEASE = "UPDATE workflow_claim SET held_until = unixepoch('now', 'subsec') WHERE workflow = ? AND token = ?"
179
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
+
180
260
  # Take the oldest visible workflow and push it a lease into the future. There is no
181
261
  # `SKIP LOCKED` here and none is wanted: it exists so one poller does not queue behind
182
262
  # another's row lock, and SQLite has no concurrent writers to step over.
@@ -204,6 +284,11 @@ ON CONFLICT (namespace, workflow) DO UPDATE SET visible_at = excluded.visible_at
204
284
  # different `visible_at`, so the equality is the whole check.
205
285
  FINISH = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ? AND visible_at = ?"
206
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
+
207
292
  # Suspend until a deadline, under the same comparison and for the same reason. A workflow
208
293
  # holds one row here, so writing the deadline unconditionally would land on top of a
209
294
  # `make_ready` that arrived while the pass was ending and push a confirmation out to a
@@ -416,6 +501,14 @@ class SqliteCheckpointer:
416
501
  rows = await self.database.run(lambda connection: connection.execute(LOAD, (workflow,)).fetchall())
417
502
  return {step: self.codec.decode(encoded) for step, encoded in rows}
418
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
+
419
512
  async def claim(self, workflow: str, lease: timedelta) -> Pass | None:
420
513
  taken = await self.database.run(
421
514
  lambda connection: connection.execute(
@@ -485,6 +578,37 @@ class SqliteCheckpointer:
485
578
  )
486
579
  return self.codec.decode(cast(tuple[str], stored)[0])
487
580
 
581
+ async def append(self, workflow: str, value: object) -> Entry:
582
+ """File `value` in this workflow's inbox, under the next key in the table."""
583
+ stored = await self.database.run(
584
+ lambda connection: connection.execute(
585
+ APPEND,
586
+ {"workflow": workflow, "value": self.codec.encode(value)},
587
+ ).fetchone()
588
+ )
589
+ key, encoded = cast(tuple[str, str], stored)
590
+ return Entry(key=key, value=self.codec.decode(encoded))
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
+
488
612
  async def release(self, holder: Pass) -> None:
489
613
  await self.database.run(lambda connection: connection.execute(RELEASE, (holder.workflow, holder.token)))
490
614
 
@@ -593,6 +717,22 @@ class SqliteScheduler:
593
717
  """Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
594
718
  return None
595
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
+
596
736
  async def done(self, delivery: Delivery) -> None:
597
737
  """
598
738
  Drop the workflow, unless something asked for another pass while this one ran.
@@ -644,3 +784,39 @@ class SqliteDurable:
644
784
  return self.checkpointer.codec.decode(cast(tuple[str], stored)[0])
645
785
 
646
786
  return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
787
+
788
+ async def deliver(self, workflow: str, value: object) -> Entry:
789
+ """Append the message and make the workflow ready, together or not at all."""
790
+ visible_at = self.scheduler.now().timestamp()
791
+
792
+ def one_commit(cursor: sqlite3.Cursor) -> Entry:
793
+ stored = cursor.execute(
794
+ APPEND,
795
+ {"workflow": workflow, "value": self.checkpointer.codec.encode(value)},
796
+ ).fetchone()
797
+ cursor.execute(
798
+ SCHEDULE,
799
+ {"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": visible_at},
800
+ )
801
+ key, encoded = cast(tuple[str, str], stored)
802
+ return Entry(key=key, value=self.checkpointer.codec.decode(encoded))
803
+
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))