honker 0.3.0 → 0.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d4ec2fd1faf6bbe935f087e8b6bcc28e875ee35c5c1212106d4d6fcf4e4eb70
4
- data.tar.gz: fbec7fc3f448934b848f2aa4af73c257aacb2dbb6e2bfc1ce9a04944b2b5cff1
3
+ metadata.gz: c0cbbc6a6ec693c1d719be5d556d216d19bf1f975c42d8ccdf599337117d052d
4
+ data.tar.gz: 8c2af61f7af5c590057d12bddbe73c99050b818afaab961301b2c0b91f1f7fe7
5
5
  SHA512:
6
- metadata.gz: 8c20783f45f36b00c477c656843aa786b062990bc7b419034005a039dfce70705142716dda245168d3027e5f51e0c74091ce4bdaa9d3afb577745f999d9ee373
7
- data.tar.gz: 75dd17ed09ca7ee424cf145f64a6d94abedfa960b6864001b6a7ef29cee2cd2e0338cc9a67abfe491dc36b83bbf913f6b26468ecfc07aaf54a93e18e8f8aa725
6
+ metadata.gz: a07431eb49bb65c4ead35b9ab4b5d5259b7d766b77a20be36b6d8ed6f2da6f9280adc687b56e2624954a3a5ac4a58b7a6d2f9117205abfd09d0c78730d31b2a2
7
+ data.tar.gz: 84e314eda52d30700270a83c5ca8b32956a7ab78f8bce427303562c1186d938dd7802148a2cf9e32f5e57e2e40a530d535e6af89c838d35007fc1b0c3198189b
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "honker-core"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  edition = "2024"
5
5
  description = "Shared Rust foundation for Honker bindings (SQLite loadable extension, PyO3, napi-rs, and friends). Not intended for direct use."
6
6
  license = "MIT OR Apache-2.0"
@@ -990,24 +990,14 @@ pub fn rate_limit_try(
990
990
  rusqlite::params![per],
991
991
  |r| r.get(0),
992
992
  )?;
993
- let current: i64 = conn
994
- .query_row(
995
- "SELECT COALESCE(MAX(count), 0) FROM _honker_rate_limits
996
- WHERE name = ?1 AND window_start = ?2",
997
- rusqlite::params![name, window_start],
998
- |r| r.get(0),
999
- )
1000
- .unwrap_or(0);
1001
- if current >= limit {
1002
- return Ok(0);
1003
- }
1004
- conn.execute(
993
+ let changed = conn.execute(
1005
994
  "INSERT INTO _honker_rate_limits (name, window_start, count)
1006
995
  VALUES (?1, ?2, 1)
1007
- ON CONFLICT(name, window_start) DO UPDATE SET count = count + 1",
1008
- rusqlite::params![name, window_start],
996
+ ON CONFLICT(name, window_start) DO UPDATE SET count = count + 1
997
+ WHERE count < ?3",
998
+ rusqlite::params![name, window_start, limit],
1009
999
  )?;
1010
- Ok(1)
1000
+ Ok(if changed > 0 { 1 } else { 0 })
1011
1001
  }
1012
1002
 
1013
1003
  pub fn rate_limit_sweep(conn: &Connection, older_than_s: i64) -> rusqlite::Result<i64> {
@@ -1269,10 +1259,8 @@ pub fn scheduler_update(
1269
1259
  if !exists {
1270
1260
  return Ok(0);
1271
1261
  }
1272
- let any_field = cron_expr.is_some()
1273
- || payload.is_some()
1274
- || priority.is_some()
1275
- || expires_s.is_some();
1262
+ let any_field =
1263
+ cron_expr.is_some() || payload.is_some() || priority.is_some() || expires_s.is_some();
1276
1264
  if !any_field {
1277
1265
  // No fields to change. Don't wake the leader for a no-op.
1278
1266
  return Ok(0);
@@ -1316,8 +1304,10 @@ pub fn scheduler_update(
1316
1304
  Ok(())
1317
1305
  })();
1318
1306
  if result.is_err() {
1319
- let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT honker_sched_update; \
1320
- RELEASE SAVEPOINT honker_sched_update");
1307
+ let _ = conn.execute_batch(
1308
+ "ROLLBACK TO SAVEPOINT honker_sched_update; \
1309
+ RELEASE SAVEPOINT honker_sched_update",
1310
+ );
1321
1311
  result?;
1322
1312
  }
1323
1313
  conn.execute_batch("RELEASE SAVEPOINT honker_sched_update")?;
@@ -24,7 +24,7 @@
24
24
  //! acquire, non-blocking try_acquire, and release.
25
25
  //! - [`Readers`] — bounded pool of reader connections that open
26
26
  //! lazily up to a max.
27
- //! - [`UpdateWatcher`] — 1 ms PRAGMA-polling thread that fires a
27
+ //! - [`UpdateWatcher`] — PRAGMA-polling thread that fires a
28
28
  //! callback on every database commit. Uses `PRAGMA data_version`
29
29
  //! for precise change detection, with a periodic stat identity check
30
30
  //! to detect file replacement. Bindings wrap this to surface wake
@@ -59,13 +59,13 @@ use std::time::{Duration, Instant};
59
59
 
60
60
  /// Which backend drives the update-detection loop.
61
61
  ///
62
- /// `Polling` is the default: 1 ms `PRAGMA data_version` loop, proven
62
+ /// `Polling` is the default: `PRAGMA data_version` loop, proven
63
63
  /// correct across all platforms. The optional backends are **experimental**
64
64
  /// — they must first prove equivalence to the polling path before
65
65
  /// being relied on for correctness.
66
66
  #[derive(Debug, Clone, Default)]
67
67
  pub enum WatcherBackend {
68
- /// Default: 1 ms `PRAGMA data_version` polling loop.
68
+ /// Default: `PRAGMA data_version` polling loop.
69
69
  #[default]
70
70
  Polling,
71
71
  /// OS kernel filesystem notifications (experimental).
@@ -89,11 +89,40 @@ pub enum WatcherBackend {
89
89
  ShmFastPath,
90
90
  }
91
91
 
92
+ pub const DEFAULT_WATCHER_POLL_INTERVAL: Duration = Duration::from_millis(1);
93
+
92
94
  /// Configuration passed to [`UpdateWatcher::spawn_with_config`] and
93
95
  /// [`SharedUpdateWatcher::new_with_config`].
94
- #[derive(Debug, Clone, Default)]
96
+ #[derive(Debug, Clone)]
95
97
  pub struct WatcherConfig {
96
98
  pub backend: WatcherBackend,
99
+ pub poll_interval: Duration,
100
+ }
101
+
102
+ impl Default for WatcherConfig {
103
+ fn default() -> Self {
104
+ Self {
105
+ backend: WatcherBackend::default(),
106
+ poll_interval: DEFAULT_WATCHER_POLL_INTERVAL,
107
+ }
108
+ }
109
+ }
110
+
111
+ impl WatcherConfig {
112
+ pub fn with_backend(backend: WatcherBackend) -> Self {
113
+ Self {
114
+ backend,
115
+ ..Self::default()
116
+ }
117
+ }
118
+
119
+ pub fn with_poll_interval(mut self, poll_interval: Duration) -> Result<Self, String> {
120
+ if poll_interval.is_zero() {
121
+ return Err("watcher poll interval must be positive".to_string());
122
+ }
123
+ self.poll_interval = poll_interval;
124
+ Ok(self)
125
+ }
97
126
  }
98
127
 
99
128
  impl WatcherBackend {
@@ -680,9 +709,9 @@ fn is_transient_lock_error(e: &rusqlite::Error) -> bool {
680
709
  ///
681
710
  /// Three-layer defensive architecture:
682
711
  ///
683
- /// 1. **Fast path (every 1 ms):** `PRAGMA data_version`. Compare the
712
+ /// 1. **Fast path:** `PRAGMA data_version`. Compare the
684
713
  /// integer to last seen value. Notify on change. (~3.5 µs/call.)
685
- /// 2. **Error recovery (every 1 ms on failure):** If the query fails,
714
+ /// 2. **Error recovery:** If the query fails,
686
715
  /// reconnect the SQLite connection and force one wake.
687
716
  /// 3. **Identity check (about every 100 ms):** `stat(db_path)` to compare
688
717
  /// `(dev, ino)`. If the file was replaced, panic with a clear
@@ -692,6 +721,7 @@ pub(crate) fn run_poll_loop<F>(
692
721
  on_change: F,
693
722
  stop: Arc<AtomicBool>,
694
723
  ready: std::sync::mpsc::SyncSender<()>,
724
+ poll_interval: Duration,
695
725
  ) where
696
726
  F: Fn(),
697
727
  {
@@ -724,7 +754,7 @@ pub(crate) fn run_poll_loop<F>(
724
754
  drop(ready);
725
755
 
726
756
  while !stop.load(Ordering::Acquire) {
727
- std::thread::sleep(Duration::from_millis(1));
757
+ std::thread::sleep(poll_interval);
728
758
 
729
759
  // Path 1: PRAGMA data_version (fast path)
730
760
  if let Some(ref c) = conn {
@@ -836,7 +866,9 @@ impl UpdateWatcher {
836
866
  let handle = std::thread::Builder::new()
837
867
  .name("honker-update-poll".into())
838
868
  .spawn(move || match config.backend {
839
- WatcherBackend::Polling => run_poll_loop(db_path, on_change, stop_t, ready_tx),
869
+ WatcherBackend::Polling => {
870
+ run_poll_loop(db_path, on_change, stop_t, ready_tx, config.poll_interval)
871
+ }
840
872
  #[cfg(feature = "kernel-watcher")]
841
873
  WatcherBackend::KernelWatch => {
842
874
  kernel_watcher::run_kernel_watch_loop(db_path, on_change, stop_t, ready_tx);
@@ -2329,6 +2361,7 @@ while True:
2329
2361
  },
2330
2362
  WatcherConfig {
2331
2363
  backend: WatcherBackend::KernelWatch,
2364
+ ..WatcherConfig::default()
2332
2365
  },
2333
2366
  );
2334
2367
 
@@ -2407,6 +2440,7 @@ while True:
2407
2440
  },
2408
2441
  WatcherConfig {
2409
2442
  backend: WatcherBackend::ShmFastPath,
2443
+ ..WatcherConfig::default()
2410
2444
  },
2411
2445
  );
2412
2446
 
@@ -2487,7 +2521,7 @@ while True:
2487
2521
  move || {
2488
2522
  count_t.fetch_add(1, AO::Relaxed);
2489
2523
  },
2490
- WatcherConfig { backend },
2524
+ WatcherConfig::with_backend(backend),
2491
2525
  );
2492
2526
 
2493
2527
  // Drain init wakes (covers shm + kernel setup) before baseline.
@@ -2704,7 +2738,7 @@ while True:
2704
2738
  .expect("wake_times mutex poisoned")
2705
2739
  .push(std::time::Instant::now());
2706
2740
  },
2707
- WatcherConfig { backend },
2741
+ WatcherConfig::with_backend(backend),
2708
2742
  );
2709
2743
 
2710
2744
  // Drain initialization wakes.
@@ -2768,7 +2802,10 @@ while True:
2768
2802
  ignore = "notify/kqueue can drop the watcher thread under CI load; functional kernel watcher tests still run"
2769
2803
  )]
2770
2804
  #[cfg(feature = "kernel-watcher")]
2771
- #[cfg_attr(target_os = "macos", ignore = "kqueue under CI load may deliver zero wakes")]
2805
+ #[cfg_attr(
2806
+ target_os = "macos",
2807
+ ignore = "kqueue under CI load may deliver zero wakes"
2808
+ )]
2772
2809
  fn kernel_watcher_wake_latency_is_event_driven() {
2773
2810
  let tmp = std::env::temp_dir().join(format!(
2774
2811
  "honker-kw-lat-{}-{}",
@@ -2872,6 +2909,7 @@ while True:
2872
2909
  || {},
2873
2910
  WatcherConfig {
2874
2911
  backend: WatcherBackend::KernelWatch,
2912
+ ..WatcherConfig::default()
2875
2913
  },
2876
2914
  );
2877
2915
 
@@ -2931,6 +2969,14 @@ while True:
2931
2969
  ));
2932
2970
  }
2933
2971
 
2972
+ #[test]
2973
+ fn watcher_config_rejects_zero_poll_interval() {
2974
+ let err = WatcherConfig::default()
2975
+ .with_poll_interval(Duration::from_millis(0))
2976
+ .unwrap_err();
2977
+ assert_eq!(err, "watcher poll interval must be positive");
2978
+ }
2979
+
2934
2980
  #[test]
2935
2981
  #[cfg(not(feature = "kernel-watcher"))]
2936
2982
  fn watcher_backend_parse_rejects_uncompiled_kernel() {
@@ -3062,8 +3108,11 @@ while True:
3062
3108
  f.write_all(&buf).unwrap();
3063
3109
  }
3064
3110
 
3065
- let watcher =
3066
- UpdateWatcher::spawn_with_config(tmp.clone(), || {}, WatcherConfig { backend });
3111
+ let watcher = UpdateWatcher::spawn_with_config(
3112
+ tmp.clone(),
3113
+ || {},
3114
+ WatcherConfig::with_backend(backend),
3115
+ );
3067
3116
  // Generous initial wait so the watcher has snapshotted the
3068
3117
  // initial inode under CI scheduling pressure.
3069
3118
  std::thread::sleep(Duration::from_millis(300));
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "honker-extension"
3
- version = "0.2.3"
3
+ version = "0.2.4"
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,7 +23,7 @@ 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.3", default-features = false }
26
+ honker-core = { path = "../honker-core", version = "0.2.4", default-features = false }
27
27
  # "loadable_extension" feature makes rusqlite usable from inside a
28
28
  # sqlite3_extension_init entry point.
29
29
  rusqlite = { version = "0.39.0", features = ["functions", "hooks", "loadable_extension"] }
@@ -69,12 +69,17 @@ static NEXT_SQL_WATCHER_ID: AtomicU64 = AtomicU64::new(1);
69
69
  fn open_watcher_handle(
70
70
  db_path: &str,
71
71
  backend: Option<&str>,
72
+ watcher_poll_interval_ms: Option<u64>,
72
73
  ) -> std::result::Result<HonkerWatcherHandle, String> {
73
74
  let backend = honker_core::WatcherBackend::parse(backend.filter(|s| !s.is_empty()))?;
74
75
  backend.probe(PathBuf::from(db_path).as_path())?;
76
+ let mut config = honker_core::WatcherConfig::with_backend(backend);
77
+ if let Some(ms) = watcher_poll_interval_ms {
78
+ config = config.with_poll_interval(Duration::from_millis(ms))?;
79
+ }
75
80
  let shared = Arc::new(honker_core::SharedUpdateWatcher::new_with_config(
76
81
  PathBuf::from(db_path),
77
- honker_core::WatcherConfig { backend },
82
+ config,
78
83
  ));
79
84
  let (sub_id, rx) = shared.subscribe();
80
85
  Ok(HonkerWatcherHandle { shared, sub_id, rx })
@@ -88,7 +93,7 @@ fn attach_watcher_sql_functions(conn: &Connection) -> Result<()> {
88
93
  |ctx| {
89
94
  let db_path: String = ctx.get(0)?;
90
95
  let backend: Option<String> = ctx.get(1)?;
91
- let handle = open_watcher_handle(&db_path, backend.as_deref()).map_err(|e| {
96
+ let handle = open_watcher_handle(&db_path, backend.as_deref(), None).map_err(|e| {
92
97
  rusqlite::Error::UserFunctionError(Box::new(std::io::Error::other(e)))
93
98
  })?;
94
99
  let id = NEXT_SQL_WATCHER_ID.fetch_add(1, Ordering::Relaxed);
@@ -96,6 +101,24 @@ fn attach_watcher_sql_functions(conn: &Connection) -> Result<()> {
96
101
  Ok(id as i64)
97
102
  },
98
103
  )?;
104
+ conn.create_scalar_function(
105
+ "honker_update_watcher_open",
106
+ 3,
107
+ FunctionFlags::SQLITE_UTF8,
108
+ |ctx| {
109
+ let db_path: String = ctx.get(0)?;
110
+ let backend: Option<String> = ctx.get(1)?;
111
+ let poll_interval_ms: Option<i64> = ctx.get(2)?;
112
+ let poll_interval_ms = poll_interval_ms.map(|ms| ms.max(0) as u64);
113
+ let handle = open_watcher_handle(&db_path, backend.as_deref(), poll_interval_ms)
114
+ .map_err(|e| {
115
+ rusqlite::Error::UserFunctionError(Box::new(std::io::Error::other(e)))
116
+ })?;
117
+ let id = NEXT_SQL_WATCHER_ID.fetch_add(1, Ordering::Relaxed);
118
+ SQL_WATCHERS.lock().unwrap().insert(id, handle);
119
+ Ok(id as i64)
120
+ },
121
+ )?;
99
122
  conn.create_scalar_function(
100
123
  "honker_update_watcher_wait",
101
124
  2,
@@ -266,7 +289,46 @@ pub unsafe extern "C" fn honker_watcher_open(
266
289
  .to_str()
267
290
  .map_err(|e| format!("invalid db_path UTF-8: {e}"))?;
268
291
  let backend = unsafe { cstr_to_string(backend) }?;
269
- let handle = open_watcher_handle(path, backend.as_deref())?;
292
+ let handle = open_watcher_handle(path, backend.as_deref(), None)?;
293
+ Ok(Box::into_raw(Box::new(handle)))
294
+ })) {
295
+ Ok(Ok(ptr)) => ptr,
296
+ Ok(Err(err)) => {
297
+ unsafe { write_error(err_buf, err_buf_len, &err) };
298
+ ptr::null_mut()
299
+ }
300
+ Err(payload) => {
301
+ let err = panic_error(payload).to_string();
302
+ unsafe { write_error(err_buf, err_buf_len, &err) };
303
+ ptr::null_mut()
304
+ }
305
+ }
306
+ }
307
+
308
+ /// Open a core-backed update watcher over `db_path` with options.
309
+ ///
310
+ /// `watcher_poll_interval_ms` must be positive. Use `honker_watcher_open`
311
+ /// for the default 1 ms cadence.
312
+ ///
313
+ /// # Safety
314
+ /// All pointers must be valid NUL-terminated strings when non-null.
315
+ #[unsafe(no_mangle)]
316
+ pub unsafe extern "C" fn honker_watcher_open_v2(
317
+ db_path: *const c_char,
318
+ backend: *const c_char,
319
+ watcher_poll_interval_ms: u64,
320
+ err_buf: *mut c_char,
321
+ err_buf_len: usize,
322
+ ) -> *mut HonkerWatcherHandle {
323
+ match catch_unwind(AssertUnwindSafe(|| {
324
+ if db_path.is_null() {
325
+ return Err("db_path is null".to_string());
326
+ }
327
+ let path = unsafe { CStr::from_ptr(db_path) }
328
+ .to_str()
329
+ .map_err(|e| format!("invalid db_path UTF-8: {e}"))?;
330
+ let backend = unsafe { cstr_to_string(backend) }?;
331
+ let handle = open_watcher_handle(path, backend.as_deref(), Some(watcher_poll_interval_ms))?;
270
332
  Ok(Box::into_raw(Box::new(handle)))
271
333
  })) {
272
334
  Ok(Ok(ptr)) => ptr,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honker
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/honker.rb CHANGED
@@ -113,11 +113,11 @@ module Honker
113
113
  end
114
114
 
115
115
  class CoreWatcher
116
- def initialize(db_path, extension_path, backend)
116
+ def initialize(db_path, extension_path, backend, watcher_poll_interval_ms)
117
117
  @lib = Fiddle.dlopen(extension_path)
118
118
  @open = Fiddle::Function.new(
119
- @lib["honker_watcher_open"],
120
- [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
119
+ @lib["honker_watcher_open_v2"],
120
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
121
121
  Fiddle::TYPE_VOIDP,
122
122
  )
123
123
  @wait = Fiddle::Function.new(
@@ -131,7 +131,13 @@ module Honker
131
131
  Fiddle::TYPE_VOID,
132
132
  )
133
133
  err = "\0" * 1024
134
- @handle = @open.call(db_path.to_s, backend.to_s, err, err.bytesize)
134
+ @handle = @open.call(
135
+ db_path.to_s,
136
+ backend.to_s,
137
+ watcher_poll_interval_ms || 1,
138
+ err,
139
+ err.bytesize,
140
+ )
135
141
  return unless @handle.to_i.zero?
136
142
 
137
143
  raise ArgumentError, err.delete_suffix("\0").split("\0", 2).first
@@ -171,10 +177,14 @@ module Honker
171
177
  attr_reader :db
172
178
 
173
179
  def initialize(path, extension_path: nil, watcher_backend: nil,
180
+ watcher_poll_interval_ms: nil,
174
181
  extension_resolver: ExtensionResolver.new)
175
182
  unless watcher_backend.nil? || watcher_backend.is_a?(String)
176
183
  raise ArgumentError, "unknown watcher backend"
177
184
  end
185
+ unless watcher_poll_interval_ms.nil? || watcher_poll_interval_ms.to_i.positive?
186
+ raise ArgumentError, "watcher_poll_interval_ms must be positive"
187
+ end
178
188
 
179
189
  resolved_extension = extension_resolver.resolve(extension_path)
180
190
  @db = SQLite3::Database.new(path)
@@ -186,7 +196,7 @@ module Honker
186
196
  @db.enable_load_extension(false)
187
197
  @db.execute_batch(DEFAULT_PRAGMAS)
188
198
  @db.execute("SELECT honker_bootstrap()")
189
- @watcher = CoreWatcher.new(path, resolved_extension, watcher_backend)
199
+ @watcher = CoreWatcher.new(path, resolved_extension, watcher_backend, watcher_poll_interval_ms)
190
200
  end
191
201
 
192
202
  def close
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.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Russell Romney