honker 0.3.0 → 0.3.2

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.
@@ -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 {
@@ -318,7 +347,8 @@ pub const BOOTSTRAP_HONKER_SQL: &str = "
318
347
  priority INTEGER NOT NULL DEFAULT 0,
319
348
  expires_s INTEGER,
320
349
  next_fire_at INTEGER NOT NULL,
321
- enabled INTEGER NOT NULL DEFAULT 1
350
+ enabled INTEGER NOT NULL DEFAULT 1,
351
+ max_attempts INTEGER NOT NULL DEFAULT 3
322
352
  );
323
353
  CREATE TABLE IF NOT EXISTS _honker_results (
324
354
  job_id INTEGER PRIMARY KEY,
@@ -378,6 +408,23 @@ pub fn bootstrap_honker_schema(conn: &Connection) -> Result<(), Error> {
378
408
  Err(e) => return Err(e.into()),
379
409
  }
380
410
  }
411
+ // Migration: max_attempts on scheduler tasks (per-fire job budget).
412
+ let has_max_attempts: bool = {
413
+ let mut stmt = conn.prepare(
414
+ "SELECT 1 FROM pragma_table_info('_honker_scheduler_tasks') WHERE name='max_attempts'",
415
+ )?;
416
+ stmt.query_row([], |_| Ok(true)).unwrap_or(false)
417
+ };
418
+ if !has_max_attempts {
419
+ match conn.execute(
420
+ "ALTER TABLE _honker_scheduler_tasks ADD COLUMN max_attempts INTEGER NOT NULL DEFAULT 3",
421
+ [],
422
+ ) {
423
+ Ok(_) => {}
424
+ Err(e) if e.to_string().to_lowercase().contains("duplicate column") => {}
425
+ Err(e) => return Err(e.into()),
426
+ }
427
+ }
381
428
  Ok(())
382
429
  }
383
430
 
@@ -538,14 +585,28 @@ impl Readers {
538
585
  *out += 1;
539
586
  drop(out);
540
587
  drop(pool);
541
- let conn = open_conn(&self.path, false)?;
542
- // Re-check: if close() raced us, drop the brand-new
543
- // connection instead of handing it out.
544
- if self.closed.load(Ordering::Acquire) {
545
- drop(conn);
546
- return Err(closed_err());
588
+ match open_conn(&self.path, false) {
589
+ Ok(conn) => {
590
+ // Re-check: if close() raced us, drop the
591
+ // brand-new connection instead of handing it
592
+ // out. Release the capacity slot so a later
593
+ // open after a failed race doesn't think the
594
+ // pool is full forever.
595
+ if self.closed.load(Ordering::Acquire) {
596
+ *self.outstanding.lock() -= 1;
597
+ drop(conn);
598
+ return Err(closed_err());
599
+ }
600
+ return Ok(conn);
601
+ }
602
+ Err(e) => {
603
+ // Open failed — free the slot we reserved or
604
+ // every transient open failure permanently
605
+ // shrinks max_readers until the pool is dead.
606
+ *self.outstanding.lock() -= 1;
607
+ return Err(e);
608
+ }
547
609
  }
548
- return Ok(conn);
549
610
  }
550
611
  drop(out);
551
612
  self.available.wait(&mut pool);
@@ -680,9 +741,9 @@ fn is_transient_lock_error(e: &rusqlite::Error) -> bool {
680
741
  ///
681
742
  /// Three-layer defensive architecture:
682
743
  ///
683
- /// 1. **Fast path (every 1 ms):** `PRAGMA data_version`. Compare the
744
+ /// 1. **Fast path:** `PRAGMA data_version`. Compare the
684
745
  /// 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,
746
+ /// 2. **Error recovery:** If the query fails,
686
747
  /// reconnect the SQLite connection and force one wake.
687
748
  /// 3. **Identity check (about every 100 ms):** `stat(db_path)` to compare
688
749
  /// `(dev, ino)`. If the file was replaced, panic with a clear
@@ -692,6 +753,7 @@ pub(crate) fn run_poll_loop<F>(
692
753
  on_change: F,
693
754
  stop: Arc<AtomicBool>,
694
755
  ready: std::sync::mpsc::SyncSender<()>,
756
+ poll_interval: Duration,
695
757
  ) where
696
758
  F: Fn(),
697
759
  {
@@ -724,7 +786,7 @@ pub(crate) fn run_poll_loop<F>(
724
786
  drop(ready);
725
787
 
726
788
  while !stop.load(Ordering::Acquire) {
727
- std::thread::sleep(Duration::from_millis(1));
789
+ std::thread::sleep(poll_interval);
728
790
 
729
791
  // Path 1: PRAGMA data_version (fast path)
730
792
  if let Some(ref c) = conn {
@@ -836,7 +898,9 @@ impl UpdateWatcher {
836
898
  let handle = std::thread::Builder::new()
837
899
  .name("honker-update-poll".into())
838
900
  .spawn(move || match config.backend {
839
- WatcherBackend::Polling => run_poll_loop(db_path, on_change, stop_t, ready_tx),
901
+ WatcherBackend::Polling => {
902
+ run_poll_loop(db_path, on_change, stop_t, ready_tx, config.poll_interval)
903
+ }
840
904
  #[cfg(feature = "kernel-watcher")]
841
905
  WatcherBackend::KernelWatch => {
842
906
  kernel_watcher::run_kernel_watch_loop(db_path, on_change, stop_t, ready_tx);
@@ -1243,6 +1307,7 @@ mod tests {
1243
1307
  |r| r.get(0),
1244
1308
  )
1245
1309
  .unwrap();
1310
+ // Enqueue / stream_publish must NOT write synthetic wake rows.
1246
1311
  let enqueue_wakes: i64 = conn
1247
1312
  .query_row(
1248
1313
  "SELECT COUNT(*) FROM _honker_notifications WHERE channel='honker:pressure'",
@@ -1250,6 +1315,13 @@ mod tests {
1250
1315
  |r| r.get(0),
1251
1316
  )
1252
1317
  .unwrap();
1318
+ let stream_wakes: i64 = conn
1319
+ .query_row(
1320
+ "SELECT COUNT(*) FROM _honker_notifications WHERE channel='honker:stream:pressure-events'",
1321
+ [],
1322
+ |r| r.get(0),
1323
+ )
1324
+ .unwrap();
1253
1325
  let integrity: String = conn
1254
1326
  .query_row("PRAGMA integrity_check", [], |r| r.get(0))
1255
1327
  .unwrap();
@@ -1257,7 +1329,14 @@ mod tests {
1257
1329
  assert_eq!(dead, 0);
1258
1330
  assert_eq!(stream_rows as usize, total_jobs);
1259
1331
  assert_eq!(notes as usize, total_jobs);
1260
- assert_eq!(enqueue_wakes as usize, total_jobs);
1332
+ assert_eq!(
1333
+ enqueue_wakes, 0,
1334
+ "enqueue must not write wake notifications"
1335
+ );
1336
+ assert_eq!(
1337
+ stream_wakes, 0,
1338
+ "stream_publish must not write wake notifications"
1339
+ );
1261
1340
  assert_eq!(integrity, "ok");
1262
1341
 
1263
1342
  drop(conn);
@@ -2253,6 +2332,7 @@ while True:
2253
2332
  "expires_s",
2254
2333
  "next_fire_at",
2255
2334
  "enabled",
2335
+ "max_attempts",
2256
2336
  ],
2257
2337
  );
2258
2338
  let res_cols: Vec<String> = conn
@@ -2329,6 +2409,7 @@ while True:
2329
2409
  },
2330
2410
  WatcherConfig {
2331
2411
  backend: WatcherBackend::KernelWatch,
2412
+ ..WatcherConfig::default()
2332
2413
  },
2333
2414
  );
2334
2415
 
@@ -2407,6 +2488,7 @@ while True:
2407
2488
  },
2408
2489
  WatcherConfig {
2409
2490
  backend: WatcherBackend::ShmFastPath,
2491
+ ..WatcherConfig::default()
2410
2492
  },
2411
2493
  );
2412
2494
 
@@ -2487,7 +2569,7 @@ while True:
2487
2569
  move || {
2488
2570
  count_t.fetch_add(1, AO::Relaxed);
2489
2571
  },
2490
- WatcherConfig { backend },
2572
+ WatcherConfig::with_backend(backend),
2491
2573
  );
2492
2574
 
2493
2575
  // Drain init wakes (covers shm + kernel setup) before baseline.
@@ -2704,7 +2786,7 @@ while True:
2704
2786
  .expect("wake_times mutex poisoned")
2705
2787
  .push(std::time::Instant::now());
2706
2788
  },
2707
- WatcherConfig { backend },
2789
+ WatcherConfig::with_backend(backend),
2708
2790
  );
2709
2791
 
2710
2792
  // Drain initialization wakes.
@@ -2768,7 +2850,10 @@ while True:
2768
2850
  ignore = "notify/kqueue can drop the watcher thread under CI load; functional kernel watcher tests still run"
2769
2851
  )]
2770
2852
  #[cfg(feature = "kernel-watcher")]
2771
- #[cfg_attr(target_os = "macos", ignore = "kqueue under CI load may deliver zero wakes")]
2853
+ #[cfg_attr(
2854
+ target_os = "macos",
2855
+ ignore = "kqueue under CI load may deliver zero wakes"
2856
+ )]
2772
2857
  fn kernel_watcher_wake_latency_is_event_driven() {
2773
2858
  let tmp = std::env::temp_dir().join(format!(
2774
2859
  "honker-kw-lat-{}-{}",
@@ -2872,6 +2957,7 @@ while True:
2872
2957
  || {},
2873
2958
  WatcherConfig {
2874
2959
  backend: WatcherBackend::KernelWatch,
2960
+ ..WatcherConfig::default()
2875
2961
  },
2876
2962
  );
2877
2963
 
@@ -2931,6 +3017,14 @@ while True:
2931
3017
  ));
2932
3018
  }
2933
3019
 
3020
+ #[test]
3021
+ fn watcher_config_rejects_zero_poll_interval() {
3022
+ let err = WatcherConfig::default()
3023
+ .with_poll_interval(Duration::from_millis(0))
3024
+ .unwrap_err();
3025
+ assert_eq!(err, "watcher poll interval must be positive");
3026
+ }
3027
+
2934
3028
  #[test]
2935
3029
  #[cfg(not(feature = "kernel-watcher"))]
2936
3030
  fn watcher_backend_parse_rejects_uncompiled_kernel() {
@@ -3062,8 +3156,11 @@ while True:
3062
3156
  f.write_all(&buf).unwrap();
3063
3157
  }
3064
3158
 
3065
- let watcher =
3066
- UpdateWatcher::spawn_with_config(tmp.clone(), || {}, WatcherConfig { backend });
3159
+ let watcher = UpdateWatcher::spawn_with_config(
3160
+ tmp.clone(),
3161
+ || {},
3162
+ WatcherConfig::with_backend(backend),
3163
+ );
3067
3164
  // Generous initial wait so the watcher has snapshotted the
3068
3165
  // initial inode under CI scheduling pressure.
3069
3166
  std::thread::sleep(Duration::from_millis(300));
@@ -3113,4 +3210,98 @@ while True:
3113
3210
  "expected probe to fail for inaccessible dir, got Ok"
3114
3211
  );
3115
3212
  }
3213
+
3214
+ #[test]
3215
+ fn readers_open_failure_does_not_leak_capacity() {
3216
+ // Path under a missing directory — open_conn fails every time.
3217
+ // Without the outstanding decrement on open failure, two
3218
+ // failures with max=2 permanently fill the counter and the
3219
+ // third acquire blocks forever on the condvar.
3220
+ let r = Arc::new(Readers::new(
3221
+ "/this/parent/does/not/exist/honker-readers-leak.db".into(),
3222
+ 2,
3223
+ ));
3224
+ for i in 0..5 {
3225
+ let r = r.clone();
3226
+ let handle = std::thread::spawn(move || r.acquire());
3227
+ let result = handle.join().expect("acquire thread panicked");
3228
+ assert!(
3229
+ result.is_err(),
3230
+ "attempt {i}: expected open failure, got Ok"
3231
+ );
3232
+ }
3233
+ }
3234
+
3235
+ #[test]
3236
+ fn claim_batch_dead_letters_reclaim_past_max_attempts() {
3237
+ // Worker dies without retry/ack after the last allowed claim.
3238
+ // The next claim must dead-letter the row, not reclaim it.
3239
+ let path = temp_db("claim-max-attempts");
3240
+ let conn = open_core_test_conn(&path);
3241
+
3242
+ let job_id: i64 = conn
3243
+ .query_row(
3244
+ "SELECT honker_enqueue('q', '{\"n\":1}', NULL, NULL, 0, 2, NULL)",
3245
+ [],
3246
+ |r| r.get(0),
3247
+ )
3248
+ .unwrap();
3249
+
3250
+ // Claim 1 → attempts=1. Expire visibility.
3251
+ let claimed1: String = conn
3252
+ .query_row("SELECT honker_claim_batch('q', 'w1', 1, 30)", [], |r| {
3253
+ r.get(0)
3254
+ })
3255
+ .unwrap();
3256
+ assert!(claimed1.contains(&format!("\"id\":{job_id}")));
3257
+ conn.execute(
3258
+ "UPDATE _honker_live SET claim_expires_at = unixepoch() - 1 WHERE id=?1",
3259
+ rusqlite::params![job_id],
3260
+ )
3261
+ .unwrap();
3262
+
3263
+ // Claim 2 (reclaim) → attempts=2. Expire again.
3264
+ let claimed2: String = conn
3265
+ .query_row("SELECT honker_claim_batch('q', 'w2', 1, 30)", [], |r| {
3266
+ r.get(0)
3267
+ })
3268
+ .unwrap();
3269
+ assert!(claimed2.contains(&format!("\"id\":{job_id}")));
3270
+ conn.execute(
3271
+ "UPDATE _honker_live SET claim_expires_at = unixepoch() - 1 WHERE id=?1",
3272
+ rusqlite::params![job_id],
3273
+ )
3274
+ .unwrap();
3275
+
3276
+ // Claim 3: exhausted → empty claim, row in dead.
3277
+ let claimed3: String = conn
3278
+ .query_row("SELECT honker_claim_batch('q', 'w3', 1, 30)", [], |r| {
3279
+ r.get(0)
3280
+ })
3281
+ .unwrap();
3282
+ assert_eq!(claimed3, "[]");
3283
+
3284
+ let live: i64 = conn
3285
+ .query_row(
3286
+ "SELECT COUNT(*) FROM _honker_live WHERE id=?1",
3287
+ rusqlite::params![job_id],
3288
+ |r| r.get(0),
3289
+ )
3290
+ .unwrap();
3291
+ let (dead_attempts, last_error): (i64, String) = conn
3292
+ .query_row(
3293
+ "SELECT attempts, last_error FROM _honker_dead WHERE id=?1",
3294
+ rusqlite::params![job_id],
3295
+ |r| Ok((r.get(0)?, r.get(1)?)),
3296
+ )
3297
+ .unwrap();
3298
+ assert_eq!(live, 0);
3299
+ assert_eq!(dead_attempts, 2);
3300
+ assert_eq!(last_error, "max attempts exceeded");
3301
+
3302
+ drop(conn);
3303
+ let _ = std::fs::remove_file(&path);
3304
+ let _ = std::fs::remove_file(format!("{}-wal", path.display()));
3305
+ let _ = std::fs::remove_file(format!("{}-shm", path.display()));
3306
+ }
3116
3307
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "honker-extension"
3
- version = "0.2.3"
3
+ version = "0.2.5"
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.5", 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,
data/lib/honker/lock.rb CHANGED
@@ -56,11 +56,11 @@ module Honker
56
56
 
57
57
  # Extend the TTL. Returns true if we still hold the lock; false if
58
58
  # it was stolen (the TTL elapsed and another owner acquired it).
59
- # The underlying SQL is the same as `try_lock`, but keyed on our
60
- # existing `(name, owner)` pair so it refreshes rather than blocks.
59
+ # Uses honker_lock_renew honker_lock_acquire does not refresh
60
+ # expires_at for an existing (name, owner) row.
61
61
  def heartbeat(ttl_s:)
62
62
  @db.db.get_first_row(
63
- "SELECT honker_lock_acquire(?, ?, ?)",
63
+ "SELECT honker_lock_renew(?, ?, ?)",
64
64
  [@name, @owner, ttl_s],
65
65
  )[0] == 1
66
66
  end
@@ -43,13 +43,13 @@ module Honker
43
43
  #
44
44
  # Idempotent by `name`; registering the same name twice replaces
45
45
  # the previous row.
46
- def add(name:, queue:, cron: nil, schedule: nil, payload:, priority: 0, expires_s: nil)
46
+ def add(name:, queue:, cron: nil, schedule: nil, payload:, priority: 0, expires_s: nil, max_attempts: 3)
47
47
  expr = schedule || cron
48
48
  raise ArgumentError, "must provide cron: or schedule:" if expr.nil? || expr.empty?
49
49
 
50
50
  @db.db.get_first_row(
51
- "SELECT honker_scheduler_register(?, ?, ?, ?, ?, ?)",
52
- [name, queue, expr, JSON.dump(payload), priority, expires_s],
51
+ "SELECT honker_scheduler_register(?, ?, ?, ?, ?, ?, ?)",
52
+ [name, queue, expr, JSON.dump(payload), priority, expires_s, max_attempts],
53
53
  )
54
54
  @db.mark_updated
55
55
  nil
@@ -105,7 +105,7 @@ module Honker
105
105
 
106
106
  # Return every registered schedule with current state. Each entry
107
107
  # is a Hash with: name, queue, cron_expr, payload (JSON string),
108
- # priority, expires_s, next_fire_at, enabled.
108
+ # priority, expires_s, next_fire_at, enabled, max_attempts.
109
109
  def list
110
110
  raw = @db.db.get_first_row("SELECT honker_scheduler_list()")[0]
111
111
  return [] if raw.nil? || raw.empty?
@@ -115,9 +115,10 @@ module Honker
115
115
 
116
116
  # Mutate fields in place. Pass only the kwargs you want changed
117
117
  # (omitting a kwarg leaves the field alone). `payload: nil`
118
- # writes JSON null. Cron change recomputes next_fire_at from now.
118
+ # writes JSON null; `max_attempts: nil` resets to default 3.
119
+ # Cron change recomputes next_fire_at from now.
119
120
  # Returns true iff a row was updated.
120
- def update(name, schedule: UNSET, cron: UNSET, payload: UNSET, priority: UNSET, expires_s: UNSET)
121
+ def update(name, schedule: UNSET, cron: UNSET, payload: UNSET, priority: UNSET, expires_s: UNSET, max_attempts: UNSET)
121
122
  expr = nil
122
123
  expr = schedule if schedule != UNSET
123
124
  expr = cron if expr.nil? && cron != UNSET
@@ -126,13 +127,16 @@ module Honker
126
127
  priority_arg = (priority == UNSET) ? nil : priority
127
128
  touch_expires = (expires_s == UNSET) ? 0 : 1
128
129
  expires_arg = (expires_s == UNSET) ? nil : expires_s
130
+ touch_max_attempts = (max_attempts == UNSET) ? 0 : 1
131
+ max_attempts_arg = (max_attempts == UNSET) ? nil : max_attempts
129
132
 
130
- any_field = !expr.nil? || payload != UNSET || priority != UNSET || expires_s != UNSET
133
+ any_field = !expr.nil? || payload != UNSET || priority != UNSET || expires_s != UNSET || max_attempts != UNSET
131
134
  return false unless any_field
132
135
 
133
136
  n = @db.db.get_first_row(
134
- "SELECT honker_scheduler_update(?, ?, ?, ?, ?, ?)",
135
- [name, expr, payload_arg, priority_arg, expires_arg, touch_expires],
137
+ "SELECT honker_scheduler_update(?, ?, ?, ?, ?, ?, ?, ?)",
138
+ [name, expr, payload_arg, priority_arg, expires_arg, touch_expires,
139
+ max_attempts_arg, touch_max_attempts],
136
140
  )[0]
137
141
  @db.mark_updated if n.positive?
138
142
  n.positive?
@@ -187,18 +191,15 @@ module Honker
187
191
  def leader_loop(owner, stop_fn)
188
192
  last_heartbeat = monotonic_now
189
193
  until stop_fn.call
194
+ still_ours = lock_renew(LEADER_LOCK, owner, LOCK_TTL_S)
195
+ # IMPORTANT: if refresh failed, a new leader has the lock.
196
+ # Break out before ticking so we don't double-fire.
197
+ return unless still_ours
198
+
199
+ last_heartbeat = monotonic_now
190
200
  # tick errors escape up to `run`, which releases the lock in
191
201
  # its `ensure` before re-raising.
192
202
  tick
193
- if monotonic_now - last_heartbeat >= HEARTBEAT_S
194
- still_ours = lock_try_acquire(LEADER_LOCK, owner, LOCK_TTL_S)
195
- # IMPORTANT: if refresh failed, a new leader has the lock.
196
- # Break out of the leader loop so we don't double-fire. This
197
- # is the bug the Rust binding fixed; don't reintroduce it.
198
- return unless still_ours
199
-
200
- last_heartbeat = monotonic_now
201
- end
202
203
 
203
204
  wait_s = HEARTBEAT_S - (monotonic_now - last_heartbeat)
204
205
  wait_s = 0 if wait_s.negative?
@@ -239,6 +240,13 @@ module Honker
239
240
  )[0] == 1
240
241
  end
241
242
 
243
+ def lock_renew(name, owner, ttl_s)
244
+ @db.db.get_first_row(
245
+ "SELECT honker_lock_renew(?, ?, ?)",
246
+ [name, owner, ttl_s],
247
+ )[0] == 1
248
+ end
249
+
242
250
  def lock_release(name, owner)
243
251
  @db.db.get_first_row(
244
252
  "SELECT honker_lock_release(?, ?)",
@@ -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.2"
5
5
  end