without-durability-sqlite 0.0.2__tar.gz → 0.0.3__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.2
3
+ Version: 0.0.3
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,7 +14,7 @@ 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-durability==0.0.2
17
+ Requires-Dist: without-durability==0.0.3
18
18
  Requires-Python: >=3.14
19
19
  Description-Content-Type: text/markdown
20
20
 
@@ -30,6 +30,8 @@ from without_durability_sqlite import SqliteCheckpointer, SqliteDurable, SqliteS
30
30
  database = connect("workflows.db")
31
31
  await migrate(database)
32
32
  durable = SqliteDurable(SqliteCheckpointer(database), SqliteScheduler(database))
33
+ ...
34
+ await database.aclose()
33
35
  ```
34
36
 
35
37
  It is the smallest thing that still meets every requirement the interface states,
@@ -56,4 +58,5 @@ See the
56
58
  (with the [API reference](https://without.help/without-durability-sqlite/reference/))
57
59
  for the statements, why `connect` pays the fsync that the usual WAL advice trades
58
60
  away, why an effect here is a synchronous callback where the Postgres store's is
59
- `async`, and how the blocking driver is kept off the event loop.
61
+ `async`, how the blocking driver is kept off the event loop, and why closing goes
62
+ through `aclose` rather than the connection.
@@ -10,6 +10,8 @@ from without_durability_sqlite import SqliteCheckpointer, SqliteDurable, SqliteS
10
10
  database = connect("workflows.db")
11
11
  await migrate(database)
12
12
  durable = SqliteDurable(SqliteCheckpointer(database), SqliteScheduler(database))
13
+ ...
14
+ await database.aclose()
13
15
  ```
14
16
 
15
17
  It is the smallest thing that still meets every requirement the interface states,
@@ -36,4 +38,5 @@ See the
36
38
  (with the [API reference](https://without.help/without-durability-sqlite/reference/))
37
39
  for the statements, why `connect` pays the fsync that the usual WAL advice trades
38
40
  away, why an effect here is a synchronous callback where the Postgres store's is
39
- `async`, and how the blocking driver is kept off the event loop.
41
+ `async`, how the blocking driver is kept off the event loop, and why closing goes
42
+ through `aclose` rather than the connection.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-durability-sqlite"
7
- version = "0.0.2"
7
+ version = "0.0.3"
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"
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Topic :: Software Development :: Libraries",
21
21
  "Typing :: Typed",
22
22
  ]
23
- dependencies = ["without-durability==0.0.2"]
23
+ dependencies = ["without-durability==0.0.3"]
24
24
 
25
25
  [[project.authors]]
26
26
  name = "Josh Karpel"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "without-durability-sqlite"
7
- version = "0.0.2"
7
+ version = "0.0.3"
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,7 +24,7 @@ classifiers = [
24
24
  "Typing :: Typed",
25
25
  ]
26
26
  dependencies = [
27
- "without-durability==0.0.2",
27
+ "without-durability==0.0.3",
28
28
  ]
29
29
 
30
30
  [tool.uv.sources]
@@ -236,6 +236,7 @@ class Database:
236
236
  Build it with `connect`, which applies the pragmas that make this durable rather than
237
237
  merely persistent. Share one between the checkpoint store and the queue: that is what
238
238
  makes `SqliteDurable.arrive` a single commit, and it is checked rather than assumed.
239
+ Close it with `aclose`, never `connection.close()`, for the reason given there.
239
240
  """
240
241
 
241
242
  connection: sqlite3.Connection
@@ -272,6 +273,34 @@ class Database:
272
273
  running.add_done_callback(lambda _finished: self.guard.release())
273
274
  return await asyncio.shield(running)
274
275
 
276
+ async def aclose(self) -> None:
277
+ """
278
+ Close the connection once nobody is inside it.
279
+
280
+ The other half of `run`'s handshake, and the reason a caller must never reach
281
+ for `connection.close()` itself. `sqlite3.close()` frees the connection and
282
+ finalizes its statements; a thread still executing one is then reading freed
283
+ memory, which segfaults the process rather than raising. `run` makes that
284
+ reachable by design, since a cancelled caller unwinds while its thread runs on,
285
+ so a shutdown that follows a cancellation is exactly when the two meet: the
286
+ worker's task is cancelled, the statement it left behind is still in flight, and
287
+ the close lands on top of it.
288
+
289
+ Taking the guard is what waits that out, because `run` releases it from the
290
+ thread rather than from its caller. That wait is unbounded: an effect hung
291
+ inside a `run` thread holds the guard until it returns, and cancelling this
292
+ coroutine while it is parked on the guard abandons the close, leaving the
293
+ connection open. The guard is then let go again, so a `run` arriving after
294
+ this fails on a closed connection, which is the loud version of a bug that
295
+ would otherwise be silent.
296
+
297
+ The close itself goes to a thread like every other driver call: under WAL it
298
+ runs the final checkpoint (and, with `synchronous=FULL`, an fsync), which is
299
+ real disk I/O that does not belong on the event loop.
300
+ """
301
+ async with self.guard:
302
+ await asyncio.to_thread(self.connection.close)
303
+
275
304
 
276
305
  def connect(path: Path | str, *, timeout: timedelta = timedelta(seconds=5)) -> Database:
277
306
  """