without-durability-sqlite 0.0.8__tar.gz → 0.0.10__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.8
3
+ Version: 0.0.10
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.8
18
- Requires-Dist: without-durability==0.0.8
17
+ Requires-Dist: without-async==0.0.10
18
+ Requires-Dist: without-durability==0.0.10
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.8"
7
+ version = "0.0.10"
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.8",
25
- "without-durability==0.0.8",
24
+ "without-async==0.0.10",
25
+ "without-durability==0.0.10",
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.8"
7
+ version = "0.0.10"
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.8",
28
- "without-durability==0.0.8",
27
+ "without-async==0.0.10",
28
+ "without-durability==0.0.10",
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.
@@ -141,7 +141,15 @@ CREATE TABLE IF NOT EXISTS workflow_checkpoint (
141
141
  CREATE TABLE IF NOT EXISTS workflow_claim (
142
142
  workflow TEXT PRIMARY KEY,
143
143
  token INTEGER NOT NULL,
144
- 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
145
153
  ) WITHOUT ROWID;
146
154
 
147
155
  CREATE TABLE IF NOT EXISTS workflow_queue (
@@ -165,18 +173,62 @@ CREATE INDEX IF NOT EXISTS workflow_queue_visible_at ON workflow_queue (namespac
165
173
  # between the two. SQLite *is* the caller's machine, so that argument does not apply;
166
174
  # keeping the clock in SQL anyway costs nothing and keeps the three stores reading alike.
167
175
  CLAIM = """
168
- INSERT INTO workflow_claim (workflow, token, held_until)
169
- 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
+ )
170
183
  ON CONFLICT (workflow) DO UPDATE
171
- SET token = workflow_claim.token + 1, held_until = unixepoch('now', 'subsec') + :lease
172
- 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')
173
189
  RETURNING token
174
190
  """
175
191
 
176
- # The fenced, conditional write, as one statement. The Postgres version wraps its fence
177
- # read in a `FOR UPDATE` CTE so a claim committing mid-statement cannot go unseen; here
178
- # the statement is its own transaction and SQLite admits one writer, so selecting the
179
- # 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.
180
232
  #
181
233
  # `DO UPDATE SET value = the value already there` is a write that changes nothing and
182
234
  # therefore returns the row that was already stored, which is how a caller that lost the
@@ -237,7 +289,15 @@ LOAD = "SELECT step, value FROM workflow_checkpoint WHERE workflow = ? ORDER BY
237
289
  HISTORY = "SELECT step, value, written_at FROM workflow_checkpoint WHERE workflow = ? ORDER BY seq"
238
290
  # Hand the workflow back early, but keep the token, so the next claim gets the next
239
291
  # number up and a pass that comes back from the dead still loses.
240
- 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
+ """
241
301
 
242
302
  # Forget every record a workflow has. Paired with `SUPERSEDE` below and never run without
243
303
  # it, which is what the transaction in `discard` is for.
@@ -253,7 +313,9 @@ DISCARD = "DELETE FROM workflow_checkpoint WHERE workflow = ?"
253
313
  # ordering, not the claim.
254
314
  SUPERSEDE = """
255
315
  UPDATE workflow_claim
256
- SET token = token + 1, held_until = unixepoch('now', 'subsec')
316
+ SET token = token + 1,
317
+ held_until = unixepoch('now', 'subsec'),
318
+ alive_until = unixepoch('now', 'subsec')
257
319
  WHERE workflow = ?
258
320
  """
259
321
 
@@ -296,6 +358,19 @@ CANCEL = "DELETE FROM workflow_queue WHERE namespace = ? AND workflow = ?"
296
358
  # pass that runs sooner writes it again.
297
359
  SUSPEND = "UPDATE workflow_queue SET visible_at = ? WHERE namespace = ? AND workflow = ? AND visible_at = ?"
298
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
+
299
374
  # What an effect is for a store whose datastore is a SQLite file: a callback handed a
300
375
  # cursor already inside `transact`'s transaction.
301
376
  #
@@ -509,30 +584,65 @@ class SqliteCheckpointer:
509
584
  for step, encoded, written_at in rows
510
585
  }
511
586
 
512
- async def claim(self, workflow: str, lease: timedelta) -> Pass | None:
587
+ async def claim(self, workflow: str, budget: timedelta, alive: timedelta) -> Pass | None:
513
588
  taken = await self.database.run(
514
589
  lambda connection: connection.execute(
515
590
  CLAIM,
516
- {"workflow": workflow, "lease": lease.total_seconds()},
591
+ {
592
+ "workflow": workflow,
593
+ "budget": budget.total_seconds(),
594
+ "alive": alive.total_seconds(),
595
+ },
517
596
  ).fetchone()
518
597
  )
519
598
  if taken is None:
520
599
  return None
521
600
  return Pass(workflow=workflow, token=int(taken[0]))
522
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
+
523
631
  async def record(self, holder: Pass, key: str, value: object) -> Recorded:
524
632
  encoded = self.codec.encode(value)
525
- stored = await self.database.run(
526
- 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(
527
639
  RECORD,
528
- {
529
- "workflow": holder.workflow,
530
- "step": key,
531
- "value": encoded,
532
- "token": holder.token,
533
- },
640
+ {"workflow": holder.workflow, "step": key, "value": encoded, "token": holder.token},
534
641
  ).fetchone()
535
- )
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))
536
646
  if stored is None:
537
647
  # The statement wrote nothing, which happens for exactly one reason: the
538
648
  # `WHERE` that guards the insert compared this pass's token against the fence
@@ -554,6 +664,14 @@ class SqliteCheckpointer:
554
664
 
555
665
  The effect's result is written and read back through the codec rather than
556
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.
557
675
  """
558
676
 
559
677
  def one_commit(cursor: sqlite3.Cursor) -> object:
@@ -565,6 +683,7 @@ class SqliteCheckpointer:
565
683
  return self.codec.decode(recorded[0])
566
684
  written = self.codec.encode(effect(cursor))
567
685
  cursor.execute(WRITE, (holder.workflow, key, written))
686
+ cursor.execute(WROTE, {"workflow": holder.workflow, "token": holder.token})
568
687
  return self.codec.decode(written)
569
688
 
570
689
  return await self.database.run(lambda connection: transacted(connection, one_commit))
@@ -717,6 +836,34 @@ class SqliteScheduler:
717
836
  """Nothing to take over by hand: an abandoned workflow becomes visible on its own."""
718
837
  return None
719
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
+
720
867
  async def cancel(self, workflow: str) -> None:
721
868
  """
722
869
  Drop the workflow's row, whichever of the three things its `visible_at` means.