without-durability-sqlite 0.0.6__tar.gz → 0.0.7__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.7
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.7
18
+ Requires-Dist: without-durability==0.0.7
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.7"
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.7",
25
+ "without-durability==0.0.7",
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.7"
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.7",
28
+ "without-durability==0.0.7",
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
  #
@@ -68,8 +68,11 @@ from typing import cast
68
68
  from without_async import Milliseconds
69
69
  from without_durability.codec import JSON
70
70
  from without_durability.codec import CheckpointCodec
71
+ from without_durability.interfaces import INBOX
72
+ from without_durability.interfaces import INBOX_DIGITS
71
73
  from without_durability.interfaces import LEASE
72
74
  from without_durability.interfaces import Delivery
75
+ from without_durability.interfaces import Entry
73
76
  from without_durability.interfaces import Fenced
74
77
  from without_durability.interfaces import Pass
75
78
  from without_durability.interfaces import Recorded
@@ -89,19 +92,41 @@ BUSY_TIMEOUT = Milliseconds(5_000)
89
92
  # `value` is TEXT rather than a richer type, which is the same shape the Redis store's
90
93
  # hash field has and leaves the same question open: what goes *in* the text is the
91
94
  # 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.
95
+ # checkpoint readable by anything that can open the file.
95
96
  #
96
97
  # `NOT NULL` on `value` keeps "no row" and "a row holding JSON null" distinguishable, so
97
98
  # a step that legitimately records `None` is not read back as a step that never ran.
99
+ #
100
+ # `seq` is what `load`'s ordering guarantee rests on, and it is the same column Postgres
101
+ # names: assigned on insert, in insertion order, and left alone by the conflict update
102
+ # below, which is exactly the property the guarantee needs. The writer that first recorded
103
+ # a step decides where it sits, and a later write that loses moves neither the value nor
104
+ # the position. Nothing else here is a candidate, since the only other order on offer is
105
+ # the unique index's, which is step name: a different question with a plausible enough
106
+ # answer to pass a careless test.
107
+ #
108
+ # `INTEGER PRIMARY KEY` makes it an alias for the rowid rather than a column beside one,
109
+ # so it costs no storage and needs no sequence. Naming it is not decoration either. SQLite
110
+ # reserves the right to renumber rowids in "any tables that do not have an explicit INTEGER
111
+ # PRIMARY KEY" when a database is `VACUUM`ed, and this store hands an operator a file it
112
+ # invites any tool to open, so the guarantee would otherwise rest on a promise SQLite
113
+ # declines to make. Declaring the column is what puts this table outside that sentence.
114
+ #
115
+ # `(workflow, step)` is `UNIQUE` rather than the primary key because a table gets one
116
+ # primary key and `seq` is now it. Nothing else changes: both columns are `NOT NULL`, so
117
+ # the constraint admits exactly the rows the primary key did, and it is still what the
118
+ # upserts below name as their conflict target.
119
+ #
120
+ # The claim and queue tables stay `WITHOUT ROWID` because each is addressed by its primary
121
+ # key and never by a position, so for those the indirection would be pure overhead.
98
122
  SCHEMA = """
99
123
  CREATE TABLE IF NOT EXISTS workflow_checkpoint (
124
+ seq INTEGER PRIMARY KEY,
100
125
  workflow TEXT NOT NULL,
101
126
  step TEXT NOT NULL,
102
127
  value TEXT NOT NULL,
103
- PRIMARY KEY (workflow, step)
104
- ) WITHOUT ROWID;
128
+ UNIQUE (workflow, step)
129
+ );
105
130
 
106
131
  CREATE TABLE IF NOT EXISTS workflow_claim (
107
132
  workflow TEXT PRIMARY KEY,
@@ -169,10 +194,36 @@ ON CONFLICT (workflow, step) DO UPDATE SET value = workflow_checkpoint.value
169
194
  RETURNING value
170
195
  """
171
196
 
197
+ # `supply` under a key this statement mints instead of one the caller brought: the append
198
+ # that puts a message in a workflow's inbox.
199
+ #
200
+ # The number is the highest `seq` in the table plus one, which is global rather than per
201
+ # workflow and so leaves gaps in any one workflow's run of keys. That is what the contract
202
+ # allows and what makes this a single statement: a per-workflow count would be a second
203
+ # read, and reading a maximum and then inserting against it is the exact race that would
204
+ # need a transaction to close. There are no concurrent writers here to lose it to, since
205
+ # SQLite admits one, but the store that has them (Postgres) reaches for the same shape and
206
+ # the two are easier to hold in one head this way.
207
+ #
208
+ # There is no `ON CONFLICT` clause and deliberately none: the key is fresh by construction,
209
+ # so a collision means the numbering is broken and a `UNIQUE` violation is the loud version
210
+ # of that. An upsert here would quietly hand back somebody else's message instead.
211
+ APPEND = f"""
212
+ INSERT INTO workflow_checkpoint (workflow, step, value)
213
+ SELECT
214
+ :workflow,
215
+ '{INBOX}' || printf('%0{INBOX_DIGITS}d', COALESCE((SELECT MAX(seq) FROM workflow_checkpoint), 0) + 1),
216
+ :value
217
+ RETURNING step, value
218
+ """
219
+
172
220
  FENCE = "SELECT token FROM workflow_claim WHERE workflow = ?"
173
221
  ALREADY = "SELECT value FROM workflow_checkpoint WHERE workflow = ? AND step = ?"
174
222
  WRITE = "INSERT INTO workflow_checkpoint (workflow, step, value) VALUES (?, ?, ?)"
175
- LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ?"
223
+ # `ORDER BY seq` is the whole of the ordering guarantee, and it is load-bearing rather
224
+ # than a formality: `WHERE workflow = ?` is served by the unique index on
225
+ # `(workflow, step)`, so without it the rows come back sorted by step name.
226
+ LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ? ORDER BY seq"
176
227
  # Hand the workflow back early, but keep the token, so the next claim gets the next
177
228
  # number up and a pass that comes back from the dead still loses.
178
229
  RELEASE = "UPDATE workflow_claim SET held_until = unixepoch('now', 'subsec') WHERE workflow = ? AND token = ?"
@@ -485,6 +536,17 @@ class SqliteCheckpointer:
485
536
  )
486
537
  return self.codec.decode(cast(tuple[str], stored)[0])
487
538
 
539
+ async def append(self, workflow: str, value: object) -> Entry:
540
+ """File `value` in this workflow's inbox, under the next key in the table."""
541
+ stored = await self.database.run(
542
+ lambda connection: connection.execute(
543
+ APPEND,
544
+ {"workflow": workflow, "value": self.codec.encode(value)},
545
+ ).fetchone()
546
+ )
547
+ key, encoded = cast(tuple[str, str], stored)
548
+ return Entry(key=key, value=self.codec.decode(encoded))
549
+
488
550
  async def release(self, holder: Pass) -> None:
489
551
  await self.database.run(lambda connection: connection.execute(RELEASE, (holder.workflow, holder.token)))
490
552
 
@@ -644,3 +706,21 @@ class SqliteDurable:
644
706
  return self.checkpointer.codec.decode(cast(tuple[str], stored)[0])
645
707
 
646
708
  return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))
709
+
710
+ async def deliver(self, workflow: str, value: object) -> Entry:
711
+ """Append the message and make the workflow ready, together or not at all."""
712
+ visible_at = self.scheduler.now().timestamp()
713
+
714
+ def one_commit(cursor: sqlite3.Cursor) -> Entry:
715
+ stored = cursor.execute(
716
+ APPEND,
717
+ {"workflow": workflow, "value": self.checkpointer.codec.encode(value)},
718
+ ).fetchone()
719
+ cursor.execute(
720
+ SCHEDULE,
721
+ {"namespace": self.scheduler.namespace, "workflow": workflow, "visible_at": visible_at},
722
+ )
723
+ key, encoded = cast(tuple[str, str], stored)
724
+ return Entry(key=key, value=self.checkpointer.codec.decode(encoded))
725
+
726
+ return await self.checkpointer.database.run(lambda connection: transacted(connection, one_commit))