honker 0.3.2 → 0.5.0

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,7 +1,8 @@
1
1
  //! Optional `-shm` fast path (feature = `shm-fast-path`).
2
2
  //!
3
3
  //! **Experimental.** Weaker correctness contract than the polling
4
- //! backend, in exchange for sub-millisecond wake latency.
4
+ //! backend, in exchange for slightly lower CPU per tick. Not lower
5
+ //! latency — see "What this backend is actually worth" below.
5
6
  //!
6
7
  //! # Contract
7
8
  //!
@@ -28,25 +29,60 @@
28
29
  //! is read with bounded positional reads instead of mmap so SQLite file
29
30
  //! churn cannot SIGBUS the host process.
30
31
  //!
32
+ //! - **`-shm` descriptors are opened once and never closed early.** SQLite
33
+ //! locks `-shm`, and on POSIX closing any descriptor for an inode drops
34
+ //! every lock the *process* holds on it. See the registry below and
35
+ //! issue #80. This is why the backend does not simply `File::open` where
36
+ //! it needs a read.
37
+ //!
38
+ //! # What this backend is actually worth
39
+ //!
40
+ //! Less than its name suggests, and you should know that before enabling
41
+ //! it. The original design mapped `-shm` and read `iChange` as a load,
42
+ //! which was ~2000x cheaper than `PRAGMA data_version`. PR #43 replaced
43
+ //! that mapping with bounded positional reads, citing SIGBUS risk.
44
+ //!
45
+ //! That risk was overstated for *this* read. SQLite's only shrink of
46
+ //! `-shm` truncates it to 3 bytes, not 0 — deliberately — and `iChange`
47
+ //! sits at offset 8, inside the zero-filled partial page that POSIX
48
+ //! guarantees is readable. Only whole pages past the end of the object
49
+ //! fault, and this module never touches those. The SIGBUS actually seen
50
+ //! in CI came from *SQLite's own* mapping, which spans regions at 32 KiB
51
+ //! and beyond, and it happened because this crate was dropping SQLite's
52
+ //! DMS lock — issue #80, fixed by the registry above.
53
+ //!
54
+ //! The bounded read stays anyway: it costs ~1 us, it removes the need to
55
+ //! reason about page boundaries at all, and restoring the mapping would
56
+ //! not help. A bounded read is a syscall (~1.2 us here versus ~2.3 us for
57
+ //! `PRAGMA data_version` on macOS/arm64), but both backends then sleep
58
+ //! 1 ms, so **wake latency is identical**. The mapping would only pay off
59
+ //! paired with a much tighter poll interval — and that trade is already
60
+ //! available on the polling backend via `WatcherConfig::poll_interval`,
61
+ //! without a second backend. Prefer polling unless you have measured a
62
+ //! reason not to.
63
+ //!
31
64
  //! Tests assert that wakes fire with sub-millisecond latency in WAL
32
65
  //! mode. If a test fails, the backend is broken — not "fall back to
33
66
  //! polling and pretend it worked".
34
67
 
35
68
  use crate::stat_identity;
36
69
  use rusqlite::{Connection, OpenFlags};
70
+ use std::collections::HashMap;
37
71
  use std::fs::File;
38
- use std::io::{Read, Seek, SeekFrom};
39
- use std::path::PathBuf;
40
- use std::sync::Arc;
72
+ use std::path::{Path, PathBuf};
41
73
  use std::sync::atomic::{AtomicBool, Ordering};
74
+ use std::sync::{Arc, Mutex, OnceLock};
42
75
  use std::time::{Duration, Instant};
43
76
 
44
77
  const WALINDEX_MAX_VERSION: u32 = 3_007_000;
45
78
  const ICHANGE_OFFSET: usize = 8;
46
- /// Same cadence as the polling backend. Shm reads are nearly free; the
47
- /// win over polling is "PRAGMA -> memory load" (~3.5 us -> ns), not
48
- /// "1 ms -> 100 us". Going faster would just burn extra sleep syscalls
49
- /// for latency nobody can perceive.
79
+ /// Same cadence as the polling backend. The win over polling is CPU per
80
+ /// tick, not latency both sleep 1 ms, so wake latency is identical.
81
+ /// Measured on macOS/arm64 against SQLite 3.51.3: `pread` of the header
82
+ /// is ~1.2 us versus ~2.3 us for `PRAGMA data_version`. That is a much
83
+ /// smaller margin than the original mmap design promised, because a
84
+ /// bounded read is a syscall and a mapped load was not. See the module
85
+ /// docs for why the mapping had to go.
50
86
  const POLL_INTERVAL_MS: u64 = 1;
51
87
  /// Cadence for the dead-man's switch (db / -shm replacement detection).
52
88
  /// Same wall-clock interval as the polling and kernel backends. Tracked
@@ -54,6 +90,181 @@ const POLL_INTERVAL_MS: u64 = 1;
54
90
  /// up to ~15 ms.
55
91
  const IDENTITY_CHECK_INTERVAL: Duration = Duration::from_millis(100);
56
92
 
93
+ // ---------------------------------------------------------------------
94
+ // `-shm` descriptor registry (issue #80)
95
+ // ---------------------------------------------------------------------
96
+ //
97
+ // SQLite takes POSIX advisory locks on `-shm` (the WAL-index locks and
98
+ // the DMS byte). On POSIX, `close()` of *any* descriptor for an inode
99
+ // releases *every* lock the calling process holds on it — including
100
+ // locks belonging to unrelated SQLite connections in the same process.
101
+ // SQLite's `unixInodeInfo` deferred-close list only defers closes of
102
+ // descriptors SQLite itself opened, so it cannot protect us.
103
+ //
104
+ // So this module must never close a `-shm` descriptor while a connection
105
+ // in this process might still hold locks on that inode. Three call sites
106
+ // used to do exactly that: `probe()` on every `honker.open()`, the
107
+ // identity-change reopen, and the watcher's own shutdown.
108
+ //
109
+ // The rule enforced here:
110
+ //
111
+ // * open each `-shm` inode at most once per process, and share it;
112
+ // * never close a descriptor whose inode still has a name;
113
+ // * reclaim descriptors once `st_nlink == 0`, which proves the inode is
114
+ // unreferenced. SQLite only unlinks `-shm` while holding an EXCLUSIVE
115
+ // lock on the database file — i.e. when no connection anywhere is
116
+ // using that WAL — so at that point there is no lock left to drop.
117
+ //
118
+ // That bounds retention to one descriptor per *live* watched database
119
+ // rather than leaking one per WAL generation.
120
+
121
+ /// How many `-shm` descriptors this process will retain before giving up.
122
+ /// Only reachable if inodes are churning faster than they can be reclaimed;
123
+ /// failing loudly beats leaking descriptors without limit.
124
+ #[cfg(unix)]
125
+ const MAX_RETAINED_SHM_FDS: usize = 256;
126
+
127
+ #[cfg(unix)]
128
+ #[derive(Default)]
129
+ struct ShmFdRegistry {
130
+ /// Current descriptor per `(dev, ino)`, shared by every watcher on it.
131
+ live: HashMap<(u64, u64), Arc<File>>,
132
+ /// Descriptors we could not file under a free key (lost an open race).
133
+ /// Held, not closed, until reclaimable.
134
+ extra: Vec<Arc<File>>,
135
+ }
136
+
137
+ #[cfg(unix)]
138
+ fn shm_registry() -> &'static Mutex<ShmFdRegistry> {
139
+ static REGISTRY: OnceLock<Mutex<ShmFdRegistry>> = OnceLock::new();
140
+ REGISTRY.get_or_init(|| Mutex::new(ShmFdRegistry::default()))
141
+ }
142
+
143
+ /// `st_nlink` for an already-open file. `0` means the inode has no
144
+ /// remaining directory entry, so nothing can lock it any more.
145
+ #[cfg(unix)]
146
+ fn link_count(file: &File) -> Option<u64> {
147
+ use std::os::unix::fs::MetadataExt;
148
+ file.metadata().ok().map(|m| m.nlink())
149
+ }
150
+
151
+ #[cfg(unix)]
152
+ impl ShmFdRegistry {
153
+ /// Drop every descriptor whose inode is provably unreferenced and that
154
+ /// no watcher still holds. Cheap: one `fstat` per retained descriptor,
155
+ /// run only on acquire.
156
+ fn reclaim_unlinked(&mut self) {
157
+ self.live
158
+ .retain(|_, fd| Arc::strong_count(fd) > 1 || link_count(fd) != Some(0));
159
+ self.extra
160
+ .retain(|fd| Arc::strong_count(fd) > 1 || link_count(fd) != Some(0));
161
+ }
162
+
163
+ fn retained(&self) -> usize {
164
+ self.live.len() + self.extra.len()
165
+ }
166
+ }
167
+
168
+ /// Get a shared descriptor for `path`, opening it only if this process
169
+ /// does not already hold one for that inode.
170
+ ///
171
+ /// The `stat`-before-`open` step is not an optimization — it is the whole
172
+ /// point. Opening a second descriptor just to discover we already had one
173
+ /// would mean closing it, and that close is the bug.
174
+ #[cfg(unix)]
175
+ fn acquire_shm_fd(path: &Path) -> std::io::Result<Arc<File>> {
176
+ let existing_id = stat_identity(path).ok();
177
+ let mut reg = shm_registry().lock().unwrap_or_else(|e| e.into_inner());
178
+ reg.reclaim_unlinked();
179
+
180
+ if let Some(id) = existing_id
181
+ && let Some(fd) = reg.live.get(&id)
182
+ {
183
+ return Ok(Arc::clone(fd));
184
+ }
185
+
186
+ if reg.retained() >= MAX_RETAINED_SHM_FDS {
187
+ return Err(std::io::Error::other(format!(
188
+ "shm-fast-path is holding {MAX_RETAINED_SHM_FDS} -shm descriptors it \
189
+ cannot safely close; refusing to open more. Closing them would \
190
+ release this process's SQLite WAL-index locks (issue #80)."
191
+ )));
192
+ }
193
+
194
+ let file = Arc::new(File::open(path)?);
195
+ // Re-identify from the descriptor: `path` may have been replaced
196
+ // between the stat above and this open.
197
+ let id = match stat_identity_of(&file) {
198
+ Some(id) => id,
199
+ None => {
200
+ reg.extra.push(Arc::clone(&file));
201
+ return Ok(file);
202
+ }
203
+ };
204
+ match reg.live.entry(id) {
205
+ std::collections::hash_map::Entry::Occupied(slot) => {
206
+ // Raced with another thread. Keep ours alive rather than
207
+ // closing it, and hand back the descriptor already on file.
208
+ let winner = Arc::clone(slot.get());
209
+ reg.extra.push(file);
210
+ Ok(winner)
211
+ }
212
+ std::collections::hash_map::Entry::Vacant(slot) => {
213
+ slot.insert(Arc::clone(&file));
214
+ Ok(file)
215
+ }
216
+ }
217
+ }
218
+
219
+ /// `(dev, ino)` of an open file, matching [`crate::stat_identity`]'s shape.
220
+ #[cfg(unix)]
221
+ fn stat_identity_of(file: &File) -> Option<(u64, u64)> {
222
+ use std::os::unix::fs::MetadataExt;
223
+ let m = file.metadata().ok()?;
224
+ Some((m.dev(), m.ino()))
225
+ }
226
+
227
+ /// Windows has no POSIX advisory locks — closing a handle cannot release
228
+ /// another connection's lock — so the retention machinery above is not
229
+ /// needed and would only keep handles alive on a live `-shm`.
230
+ #[cfg(not(unix))]
231
+ fn acquire_shm_fd(path: &Path) -> std::io::Result<Arc<File>> {
232
+ File::open(path).map(Arc::new)
233
+ }
234
+
235
+ /// A `-shm` descriptor, the 12-byte WAL-index header read from it, and
236
+ /// that file's `(dev, ino)` at the time of the read.
237
+ type ShmHeaderSnapshot = (Arc<File>, [u8; 12], (u64, u64));
238
+
239
+ /// Positional read of the WAL-index header. One syscall, and — unlike a
240
+ /// mapping — a file that shrinks underneath us yields `UnexpectedEof`
241
+ /// instead of SIGBUS. That protection came from PR #43 and must stay.
242
+ fn read_wal_index_header(file: &File) -> std::io::Result<[u8; 12]> {
243
+ let mut header = [0_u8; 12];
244
+ #[cfg(unix)]
245
+ {
246
+ use std::os::unix::fs::FileExt;
247
+ file.read_exact_at(&mut header, 0)?;
248
+ }
249
+ #[cfg(windows)]
250
+ {
251
+ use std::os::windows::fs::FileExt;
252
+ let mut done = 0;
253
+ while done < header.len() {
254
+ match file.seek_read(&mut header[done..], done as u64)? {
255
+ 0 => {
256
+ return Err(std::io::Error::new(
257
+ std::io::ErrorKind::UnexpectedEof,
258
+ "-shm shorter than the WAL index header",
259
+ ));
260
+ }
261
+ n => done += n,
262
+ }
263
+ }
264
+ }
265
+ Ok(header)
266
+ }
267
+
57
268
  pub(crate) fn run_shm_fast_path_loop<F>(
58
269
  db_path: PathBuf,
59
270
  on_change: F,
@@ -118,7 +329,7 @@ pub(crate) fn run_shm_fast_path_loop<F>(
118
329
 
119
330
  while !stop.load(Ordering::Acquire) {
120
331
  std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
121
- let current = match read_wal_index_header(&mut f) {
332
+ let current = match read_wal_index_header(&f) {
122
333
  Ok(header) => read_ichange_from_header(&header),
123
334
  Err(e) => {
124
335
  if let Some((new_file, new_header, new_id)) = reopen_shm_header(&shm_path) {
@@ -164,13 +375,6 @@ pub(crate) fn run_shm_fast_path_loop<F>(
164
375
  }
165
376
  }
166
377
 
167
- fn read_wal_index_header(file: &mut File) -> std::io::Result<[u8; 12]> {
168
- let mut header = [0_u8; 12];
169
- file.seek(SeekFrom::Start(0))?;
170
- file.read_exact(&mut header)?;
171
- Ok(header)
172
- }
173
-
174
378
  fn read_ichange_from_header(header: &[u8; 12]) -> u32 {
175
379
  u32::from_ne_bytes(
176
380
  header[ICHANGE_OFFSET..ICHANGE_OFFSET + 4]
@@ -179,9 +383,16 @@ fn read_ichange_from_header(header: &[u8; 12]) -> u32 {
179
383
  )
180
384
  }
181
385
 
182
- fn reopen_shm_header(path: &std::path::Path) -> Option<(File, [u8; 12], (u64, u64))> {
183
- let mut file = File::open(path).ok()?;
184
- let header = read_wal_index_header(&mut file).ok()?;
386
+ /// Re-acquire the current `-shm` descriptor and re-read its header.
387
+ ///
388
+ /// Goes through [`acquire_shm_fd`], so the descriptor this replaces is
389
+ /// retained by the registry rather than closed. Closing it here would
390
+ /// release the WAL-index locks of every SQLite connection in this
391
+ /// process — the identity-change path was one of the three sites that
392
+ /// did exactly that before issue #80.
393
+ fn reopen_shm_header(path: &std::path::Path) -> Option<ShmHeaderSnapshot> {
394
+ let file = acquire_shm_fd(path).ok()?;
395
+ let header = read_wal_index_header(&file).ok()?;
185
396
  let id = stat_identity(path).ok()?;
186
397
  Some((file, header, id))
187
398
  }
@@ -189,7 +400,7 @@ fn reopen_shm_header(path: &std::path::Path) -> Option<(File, [u8; 12], (u64, u6
189
400
  fn wait_for_initial_shm_header(
190
401
  path: &std::path::Path,
191
402
  stop: &AtomicBool,
192
- ) -> Option<(File, [u8; 12], (u64, u64))> {
403
+ ) -> Option<ShmHeaderSnapshot> {
193
404
  for _ in 0..200 {
194
405
  if stop.load(Ordering::Acquire) {
195
406
  return None;
@@ -236,10 +447,14 @@ pub(crate) fn probe(db_path: &std::path::Path) -> Result<(), String> {
236
447
  return Err("shm-fast-path requires little-endian platform".into());
237
448
  }
238
449
  let shm = format!("{}-shm", db_path.display());
239
- let mut f = File::open(&shm)
450
+ // Acquire through the registry, never `File::open` + drop. `probe`
451
+ // runs at `honker.open()` time, *after* the caller's writer connection
452
+ // has already created -wal/-shm, so a bare open+close here released
453
+ // this process's SQLite WAL-index locks on every single open.
454
+ let f = acquire_shm_fd(std::path::Path::new(&shm))
240
455
  .map_err(|e| format!("-shm unavailable ({e}). WAL mode + open connection required."))?;
241
456
  let header =
242
- read_wal_index_header(&mut f).map_err(|e| format!("-shm too small or unreadable: {e}"))?;
457
+ read_wal_index_header(&f).map_err(|e| format!("-shm too small or unreadable: {e}"))?;
243
458
  let iv = u32::from_ne_bytes(header[0..4].try_into().unwrap());
244
459
  if iv != WALINDEX_MAX_VERSION {
245
460
  return Err(format!(
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "honker-extension"
3
- version = "0.2.5"
3
+ version = "0.5.0"
4
4
  edition = "2024"
5
5
  description = "SQLite loadable extension for Honker. Adds honker_* SQL functions (queues, streams, scheduler, pub/sub) to any SQLite client."
6
6
  license = "MIT OR Apache-2.0"
@@ -23,14 +23,15 @@ crate-type = ["cdylib"]
23
23
  # Using both `path` and `version` lets Cargo publish this crate to
24
24
  # crates.io referencing the real honker-core = "0.2" while still
25
25
  # using the in-tree source for local builds.
26
- honker-core = { path = "../honker-core", version = "0.2.5", default-features = false }
26
+ honker-core = { path = "../honker-core", version = "0.5.0", default-features = false }
27
27
  # "loadable_extension" feature makes rusqlite usable from inside a
28
28
  # sqlite3_extension_init entry point.
29
- rusqlite = { version = "0.39.0", features = ["functions", "hooks", "loadable_extension"] }
29
+ rusqlite = { version = "0.40.1", features = ["functions", "hooks", "loadable_extension"] }
30
30
 
31
31
  [features]
32
32
  default = []
33
33
  kernel-watcher = ["honker-core/kernel-watcher"]
34
34
  shm-fast-path = ["honker-core/shm-fast-path"]
35
35
 
36
+
36
37
  [workspace]
@@ -108,7 +108,7 @@ fn attach_watcher_sql_functions(conn: &Connection) -> Result<()> {
108
108
  |ctx| {
109
109
  let db_path: String = ctx.get(0)?;
110
110
  let backend: Option<String> = ctx.get(1)?;
111
- let poll_interval_ms: Option<i64> = ctx.get(2)?;
111
+ let poll_interval_ms: Option<i64> = honker_core::arg_opt_i64(ctx, 2)?;
112
112
  let poll_interval_ms = poll_interval_ms.map(|ms| ms.max(0) as u64);
113
113
  let handle = open_watcher_handle(&db_path, backend.as_deref(), poll_interval_ms)
114
114
  .map_err(|e| {
@@ -124,8 +124,8 @@ fn attach_watcher_sql_functions(conn: &Connection) -> Result<()> {
124
124
  2,
125
125
  FunctionFlags::SQLITE_UTF8,
126
126
  |ctx| {
127
- let id: i64 = ctx.get(0)?;
128
- let timeout_ms: i64 = ctx.get(1)?;
127
+ let id: i64 = honker_core::arg_i64(ctx, 0)?;
128
+ let timeout_ms: i64 = honker_core::arg_i64(ctx, 1)?;
129
129
  let Some(handle) = SQL_WATCHERS.lock().unwrap().remove(&(id as u64)) else {
130
130
  return Ok(-1);
131
131
  };
@@ -149,7 +149,7 @@ fn attach_watcher_sql_functions(conn: &Connection) -> Result<()> {
149
149
  1,
150
150
  FunctionFlags::SQLITE_UTF8,
151
151
  |ctx| {
152
- let id: i64 = ctx.get(0)?;
152
+ let id: i64 = honker_core::arg_i64(ctx, 0)?;
153
153
  if let Some(handle) = SQL_WATCHERS.lock().unwrap().remove(&(id as u64)) {
154
154
  handle.shared.unsubscribe(handle.sub_id);
155
155
  let _ = handle.shared.close();
@@ -363,17 +363,15 @@ pub unsafe extern "C" fn honker_watcher_wait(
363
363
  if handle.is_null() {
364
364
  return -1;
365
365
  }
366
- match catch_unwind(AssertUnwindSafe(|| {
366
+ catch_unwind(AssertUnwindSafe(|| {
367
367
  let handle = unsafe { &mut *handle };
368
368
  match handle.rx.recv_timeout(Duration::from_millis(timeout_ms)) {
369
369
  Ok(()) => 1,
370
370
  Err(RecvTimeoutError::Timeout) => 0,
371
371
  Err(RecvTimeoutError::Disconnected) => -1,
372
372
  }
373
- })) {
374
- Ok(code) => code,
375
- Err(_) => -2,
376
- }
373
+ }))
374
+ .unwrap_or(-2)
377
375
  }
378
376
 
379
377
  /// Close a watcher opened by `honker_watcher_open`.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honker
4
- VERSION = "0.3.2"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: honker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Russell Romney