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.
- checksums.yaml +4 -4
- data/ext/honker/honker-core/Cargo.toml +2 -1
- data/ext/honker/honker-core/src/honker_ops.rs +346 -185
- data/ext/honker/honker-core/src/lib.rs +213 -22
- data/ext/honker/honker-extension/Cargo.toml +2 -2
- data/ext/honker/honker-extension/src/lib.rs +65 -3
- data/lib/honker/lock.rb +3 -3
- data/lib/honker/scheduler.rb +26 -18
- data/lib/honker/version.rb +1 -1
- data/lib/honker.rb +15 -5
- metadata +1 -1
|
@@ -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`] —
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 =>
|
|
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!(
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
+
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
60
|
-
# existing
|
|
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
|
|
63
|
+
"SELECT honker_lock_renew(?, ?, ?)",
|
|
64
64
|
[@name, @owner, ttl_s],
|
|
65
65
|
)[0] == 1
|
|
66
66
|
end
|
data/lib/honker/scheduler.rb
CHANGED
|
@@ -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
|
|
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(?, ?)",
|
data/lib/honker/version.rb
CHANGED