honker 0.1.2 → 0.3.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.
- checksums.yaml +4 -4
- data/README.md +193 -2
- data/ext/honker/extconf.rb +69 -0
- data/ext/honker/honker-core/Cargo.toml +49 -0
- data/ext/honker/honker-core/LICENSE +6 -0
- data/ext/honker/honker-core/LICENSE-APACHE +176 -0
- data/ext/honker/honker-core/LICENSE-MIT +21 -0
- data/ext/honker/honker-core/README.md +30 -0
- data/ext/honker/honker-core/src/cron.rs +473 -0
- data/ext/honker/honker-core/src/honker_ops.rs +1518 -0
- data/ext/honker/honker-core/src/kernel_watcher.rs +434 -0
- data/ext/honker/honker-core/src/lib.rs +3116 -0
- data/ext/honker/honker-core/src/shm_watcher.rs +250 -0
- data/ext/honker/honker-extension/Cargo.toml +36 -0
- data/ext/honker/honker-extension/LICENSE +6 -0
- data/ext/honker/honker-extension/LICENSE-APACHE +176 -0
- data/ext/honker/honker-extension/LICENSE-MIT +21 -0
- data/ext/honker/honker-extension/README.md +41 -0
- data/ext/honker/honker-extension/src/lib.rs +330 -0
- data/honker.gemspec +24 -1
- data/lib/honker/README.md +28 -0
- data/lib/honker/railtie.rb +11 -0
- data/lib/honker/scheduler.rb +59 -0
- data/lib/honker/version.rb +1 -1
- data/lib/honker.rb +111 -3
- metadata +39 -8
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
//! honker SQLite loadable extension.
|
|
2
|
+
//!
|
|
3
|
+
//! Thin wrapper around `honker-core`. Registers:
|
|
4
|
+
//!
|
|
5
|
+
//! * `notify()` SQL scalar function + `_honker_notifications`
|
|
6
|
+
//! table — via `honker_core::attach_notify`.
|
|
7
|
+
//! * Every `honker_*` queue / lock / rate-limit / scheduler / result
|
|
8
|
+
//! function — via `honker_core::attach_honker_functions`.
|
|
9
|
+
//!
|
|
10
|
+
//! .load ./libhonker_ext
|
|
11
|
+
//! SELECT honker_bootstrap();
|
|
12
|
+
//! INSERT INTO _honker_live (queue, payload)
|
|
13
|
+
//! VALUES ('emails', '{"to": "alice"}');
|
|
14
|
+
//! SELECT honker_claim_batch('emails', 'worker-1', 32, 300);
|
|
15
|
+
//! SELECT honker_ack_batch('[1,2,3]', 'worker-1');
|
|
16
|
+
//! SELECT notify('orders', '{"id": 42}');
|
|
17
|
+
//!
|
|
18
|
+
//! Actual SQL implementations live in `honker_core::honker_ops`
|
|
19
|
+
//! so the Python (PyO3) and Node (napi-rs) bindings can register the
|
|
20
|
+
//! same functions on their own connections without loading this
|
|
21
|
+
//! `.dylib`. One source of truth for the SQL.
|
|
22
|
+
|
|
23
|
+
use rusqlite::ffi;
|
|
24
|
+
use rusqlite::functions::FunctionFlags;
|
|
25
|
+
use rusqlite::{Connection, Error, Result};
|
|
26
|
+
use std::collections::HashMap;
|
|
27
|
+
use std::ffi::CStr;
|
|
28
|
+
use std::os::raw::{c_char, c_int};
|
|
29
|
+
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
30
|
+
use std::path::PathBuf;
|
|
31
|
+
use std::sync::Arc;
|
|
32
|
+
use std::sync::Mutex as StdMutex;
|
|
33
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
34
|
+
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
|
35
|
+
use std::time::Duration;
|
|
36
|
+
use std::{ptr, sync::LazyLock};
|
|
37
|
+
|
|
38
|
+
fn panic_error(payload: Box<dyn std::any::Any + Send>) -> Error {
|
|
39
|
+
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
|
|
40
|
+
*s
|
|
41
|
+
} else if let Some(s) = payload.downcast_ref::<String>() {
|
|
42
|
+
s.as_str()
|
|
43
|
+
} else {
|
|
44
|
+
"non-string panic payload"
|
|
45
|
+
};
|
|
46
|
+
Error::UserFunctionError(Box::new(std::io::Error::other(format!(
|
|
47
|
+
"honker extension initialization panicked: {msg}"
|
|
48
|
+
))))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fn extension_init(conn: Connection) -> Result<bool> {
|
|
52
|
+
match catch_unwind(AssertUnwindSafe(|| {
|
|
53
|
+
honker_core::attach_notify(&conn).map_err(|e| {
|
|
54
|
+
Error::UserFunctionError(Box::new(std::io::Error::other(e.to_string())))
|
|
55
|
+
})?;
|
|
56
|
+
honker_core::attach_honker_functions(&conn)?;
|
|
57
|
+
attach_watcher_sql_functions(&conn)?;
|
|
58
|
+
Ok(true)
|
|
59
|
+
})) {
|
|
60
|
+
Ok(result) => result,
|
|
61
|
+
Err(payload) => Err(panic_error(payload)),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static SQL_WATCHERS: LazyLock<StdMutex<HashMap<u64, HonkerWatcherHandle>>> =
|
|
66
|
+
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
|
67
|
+
static NEXT_SQL_WATCHER_ID: AtomicU64 = AtomicU64::new(1);
|
|
68
|
+
|
|
69
|
+
fn open_watcher_handle(
|
|
70
|
+
db_path: &str,
|
|
71
|
+
backend: Option<&str>,
|
|
72
|
+
) -> std::result::Result<HonkerWatcherHandle, String> {
|
|
73
|
+
let backend = honker_core::WatcherBackend::parse(backend.filter(|s| !s.is_empty()))?;
|
|
74
|
+
backend.probe(PathBuf::from(db_path).as_path())?;
|
|
75
|
+
let shared = Arc::new(honker_core::SharedUpdateWatcher::new_with_config(
|
|
76
|
+
PathBuf::from(db_path),
|
|
77
|
+
honker_core::WatcherConfig { backend },
|
|
78
|
+
));
|
|
79
|
+
let (sub_id, rx) = shared.subscribe();
|
|
80
|
+
Ok(HonkerWatcherHandle { shared, sub_id, rx })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fn attach_watcher_sql_functions(conn: &Connection) -> Result<()> {
|
|
84
|
+
conn.create_scalar_function(
|
|
85
|
+
"honker_update_watcher_open",
|
|
86
|
+
2,
|
|
87
|
+
FunctionFlags::SQLITE_UTF8,
|
|
88
|
+
|ctx| {
|
|
89
|
+
let db_path: String = ctx.get(0)?;
|
|
90
|
+
let backend: Option<String> = ctx.get(1)?;
|
|
91
|
+
let handle = open_watcher_handle(&db_path, backend.as_deref()).map_err(|e| {
|
|
92
|
+
rusqlite::Error::UserFunctionError(Box::new(std::io::Error::other(e)))
|
|
93
|
+
})?;
|
|
94
|
+
let id = NEXT_SQL_WATCHER_ID.fetch_add(1, Ordering::Relaxed);
|
|
95
|
+
SQL_WATCHERS.lock().unwrap().insert(id, handle);
|
|
96
|
+
Ok(id as i64)
|
|
97
|
+
},
|
|
98
|
+
)?;
|
|
99
|
+
conn.create_scalar_function(
|
|
100
|
+
"honker_update_watcher_wait",
|
|
101
|
+
2,
|
|
102
|
+
FunctionFlags::SQLITE_UTF8,
|
|
103
|
+
|ctx| {
|
|
104
|
+
let id: i64 = ctx.get(0)?;
|
|
105
|
+
let timeout_ms: i64 = ctx.get(1)?;
|
|
106
|
+
let Some(handle) = SQL_WATCHERS.lock().unwrap().remove(&(id as u64)) else {
|
|
107
|
+
return Ok(-1);
|
|
108
|
+
};
|
|
109
|
+
let timeout_ms = timeout_ms.max(0) as u64;
|
|
110
|
+
let code = match handle.rx.recv_timeout(Duration::from_millis(timeout_ms)) {
|
|
111
|
+
Ok(()) => 1,
|
|
112
|
+
Err(RecvTimeoutError::Timeout) => 0,
|
|
113
|
+
Err(RecvTimeoutError::Disconnected) => -1,
|
|
114
|
+
};
|
|
115
|
+
if code != -1 {
|
|
116
|
+
SQL_WATCHERS.lock().unwrap().insert(id as u64, handle);
|
|
117
|
+
} else {
|
|
118
|
+
handle.shared.unsubscribe(handle.sub_id);
|
|
119
|
+
let _ = handle.shared.close();
|
|
120
|
+
}
|
|
121
|
+
Ok(code)
|
|
122
|
+
},
|
|
123
|
+
)?;
|
|
124
|
+
conn.create_scalar_function(
|
|
125
|
+
"honker_update_watcher_close",
|
|
126
|
+
1,
|
|
127
|
+
FunctionFlags::SQLITE_UTF8,
|
|
128
|
+
|ctx| {
|
|
129
|
+
let id: i64 = ctx.get(0)?;
|
|
130
|
+
if let Some(handle) = SQL_WATCHERS.lock().unwrap().remove(&(id as u64)) {
|
|
131
|
+
handle.shared.unsubscribe(handle.sub_id);
|
|
132
|
+
let _ = handle.shared.close();
|
|
133
|
+
}
|
|
134
|
+
Ok(1)
|
|
135
|
+
},
|
|
136
|
+
)?;
|
|
137
|
+
Ok(())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
unsafe fn set_error_msg(
|
|
141
|
+
pz_err_msg: *mut *mut c_char,
|
|
142
|
+
p_api: *mut ffi::sqlite3_api_routines,
|
|
143
|
+
message: &str,
|
|
144
|
+
) {
|
|
145
|
+
if pz_err_msg.is_null() || p_api.is_null() {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let Some(malloc) = (unsafe { (*p_api).malloc }) else {
|
|
149
|
+
return;
|
|
150
|
+
};
|
|
151
|
+
let len = match message.len().checked_add(1) {
|
|
152
|
+
Some(len) if c_int::try_from(len).is_ok() => len,
|
|
153
|
+
_ => return,
|
|
154
|
+
};
|
|
155
|
+
let ptr = unsafe { malloc(len as c_int) }.cast::<c_char>();
|
|
156
|
+
if ptr.is_null() {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
unsafe {
|
|
160
|
+
ptr::copy_nonoverlapping(message.as_ptr().cast::<c_char>(), ptr, message.len());
|
|
161
|
+
*ptr.add(message.len()) = 0;
|
|
162
|
+
*pz_err_msg = ptr;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
unsafe fn extension_init2(
|
|
167
|
+
db: *mut ffi::sqlite3,
|
|
168
|
+
pz_err_msg: *mut *mut c_char,
|
|
169
|
+
p_api: *mut ffi::sqlite3_api_routines,
|
|
170
|
+
) -> c_int {
|
|
171
|
+
if p_api.is_null() {
|
|
172
|
+
return ffi::SQLITE_ERROR;
|
|
173
|
+
}
|
|
174
|
+
let result = unsafe { ffi::rusqlite_extension_init2(p_api) }
|
|
175
|
+
.map_err(Error::from)
|
|
176
|
+
.and_then(|()| unsafe { Connection::from_handle(db) })
|
|
177
|
+
.and_then(extension_init);
|
|
178
|
+
match result {
|
|
179
|
+
Ok(true) => ffi::SQLITE_OK_LOAD_PERMANENTLY,
|
|
180
|
+
Ok(false) => ffi::SQLITE_OK,
|
|
181
|
+
Err(err) => {
|
|
182
|
+
unsafe { set_error_msg(pz_err_msg, p_api, &err.to_string()) };
|
|
183
|
+
ffi::SQLITE_ERROR
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// SQLite entry point. Name must match `sqlite3_<extname>_init`; SQLite
|
|
189
|
+
/// derives `<extname>` from the filename — stripping the `lib` prefix
|
|
190
|
+
/// and any non-alphabetic characters:
|
|
191
|
+
/// `libhonker_ext.dylib` -> `honker_ext` -> `honkerext`
|
|
192
|
+
/// -> `sqlite3_honkerext_init`.
|
|
193
|
+
///
|
|
194
|
+
/// # Safety
|
|
195
|
+
/// Called by SQLite. All pointers are SQLite-owned.
|
|
196
|
+
#[unsafe(no_mangle)]
|
|
197
|
+
pub unsafe extern "C" fn sqlite3_honkerext_init(
|
|
198
|
+
db: *mut ffi::sqlite3,
|
|
199
|
+
pz_err_msg: *mut *mut c_char,
|
|
200
|
+
p_api: *mut ffi::sqlite3_api_routines,
|
|
201
|
+
) -> c_int {
|
|
202
|
+
match catch_unwind(AssertUnwindSafe(|| unsafe {
|
|
203
|
+
extension_init2(db, pz_err_msg, p_api)
|
|
204
|
+
})) {
|
|
205
|
+
Ok(code) => code,
|
|
206
|
+
Err(payload) => {
|
|
207
|
+
let err = panic_error(payload);
|
|
208
|
+
unsafe { set_error_msg(pz_err_msg, p_api, &err.to_string()) };
|
|
209
|
+
ffi::SQLITE_ERROR
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
pub struct HonkerWatcherHandle {
|
|
215
|
+
shared: Arc<honker_core::SharedUpdateWatcher>,
|
|
216
|
+
sub_id: u64,
|
|
217
|
+
rx: Receiver<()>,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
unsafe fn cstr_to_string(ptr: *const c_char) -> std::result::Result<Option<String>, String> {
|
|
221
|
+
if ptr.is_null() {
|
|
222
|
+
return Ok(None);
|
|
223
|
+
}
|
|
224
|
+
let s = unsafe { CStr::from_ptr(ptr) }
|
|
225
|
+
.to_str()
|
|
226
|
+
.map_err(|e| format!("invalid UTF-8: {e}"))?;
|
|
227
|
+
if s.is_empty() {
|
|
228
|
+
Ok(None)
|
|
229
|
+
} else {
|
|
230
|
+
Ok(Some(s.to_string()))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
unsafe fn write_error(buf: *mut c_char, len: usize, message: &str) {
|
|
235
|
+
if buf.is_null() || len == 0 {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
let bytes = message.as_bytes();
|
|
239
|
+
let copy_len = bytes.len().min(len.saturating_sub(1));
|
|
240
|
+
unsafe {
|
|
241
|
+
ptr::copy_nonoverlapping(bytes.as_ptr().cast::<c_char>(), buf, copy_len);
|
|
242
|
+
*buf.add(copy_len) = 0;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// Open a core-backed update watcher over `db_path`.
|
|
247
|
+
///
|
|
248
|
+
/// Returns null on error and writes a NUL-terminated diagnostic into
|
|
249
|
+
/// `err_buf` when provided. `backend` accepts the same exact aliases as
|
|
250
|
+
/// `honker_core::WatcherBackend::parse`; null / empty means polling.
|
|
251
|
+
///
|
|
252
|
+
/// # Safety
|
|
253
|
+
/// All pointers must be valid NUL-terminated strings when non-null.
|
|
254
|
+
#[unsafe(no_mangle)]
|
|
255
|
+
pub unsafe extern "C" fn honker_watcher_open(
|
|
256
|
+
db_path: *const c_char,
|
|
257
|
+
backend: *const c_char,
|
|
258
|
+
err_buf: *mut c_char,
|
|
259
|
+
err_buf_len: usize,
|
|
260
|
+
) -> *mut HonkerWatcherHandle {
|
|
261
|
+
match catch_unwind(AssertUnwindSafe(|| {
|
|
262
|
+
if db_path.is_null() {
|
|
263
|
+
return Err("db_path is null".to_string());
|
|
264
|
+
}
|
|
265
|
+
let path = unsafe { CStr::from_ptr(db_path) }
|
|
266
|
+
.to_str()
|
|
267
|
+
.map_err(|e| format!("invalid db_path UTF-8: {e}"))?;
|
|
268
|
+
let backend = unsafe { cstr_to_string(backend) }?;
|
|
269
|
+
let handle = open_watcher_handle(path, backend.as_deref())?;
|
|
270
|
+
Ok(Box::into_raw(Box::new(handle)))
|
|
271
|
+
})) {
|
|
272
|
+
Ok(Ok(ptr)) => ptr,
|
|
273
|
+
Ok(Err(err)) => {
|
|
274
|
+
unsafe { write_error(err_buf, err_buf_len, &err) };
|
|
275
|
+
ptr::null_mut()
|
|
276
|
+
}
|
|
277
|
+
Err(payload) => {
|
|
278
|
+
let err = panic_error(payload).to_string();
|
|
279
|
+
unsafe { write_error(err_buf, err_buf_len, &err) };
|
|
280
|
+
ptr::null_mut()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// Wait for the next database update.
|
|
286
|
+
///
|
|
287
|
+
/// Returns:
|
|
288
|
+
/// * `1` when an update was observed
|
|
289
|
+
/// * `0` on timeout
|
|
290
|
+
/// * `-1` when the watcher/subscription has closed or died
|
|
291
|
+
/// * `-2` if this function catches an internal panic
|
|
292
|
+
///
|
|
293
|
+
/// # Safety
|
|
294
|
+
/// `handle` must be a pointer returned by `honker_watcher_open` and not
|
|
295
|
+
/// yet passed to `honker_watcher_close`.
|
|
296
|
+
#[unsafe(no_mangle)]
|
|
297
|
+
pub unsafe extern "C" fn honker_watcher_wait(
|
|
298
|
+
handle: *mut HonkerWatcherHandle,
|
|
299
|
+
timeout_ms: u64,
|
|
300
|
+
) -> c_int {
|
|
301
|
+
if handle.is_null() {
|
|
302
|
+
return -1;
|
|
303
|
+
}
|
|
304
|
+
match catch_unwind(AssertUnwindSafe(|| {
|
|
305
|
+
let handle = unsafe { &mut *handle };
|
|
306
|
+
match handle.rx.recv_timeout(Duration::from_millis(timeout_ms)) {
|
|
307
|
+
Ok(()) => 1,
|
|
308
|
+
Err(RecvTimeoutError::Timeout) => 0,
|
|
309
|
+
Err(RecvTimeoutError::Disconnected) => -1,
|
|
310
|
+
}
|
|
311
|
+
})) {
|
|
312
|
+
Ok(code) => code,
|
|
313
|
+
Err(_) => -2,
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// Close a watcher opened by `honker_watcher_open`.
|
|
318
|
+
///
|
|
319
|
+
/// # Safety
|
|
320
|
+
/// `handle` must be null or a pointer returned by `honker_watcher_open`.
|
|
321
|
+
/// Passing the same non-null pointer twice is undefined behavior.
|
|
322
|
+
#[unsafe(no_mangle)]
|
|
323
|
+
pub unsafe extern "C" fn honker_watcher_close(handle: *mut HonkerWatcherHandle) {
|
|
324
|
+
if handle.is_null() {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
let handle = unsafe { Box::from_raw(handle) };
|
|
328
|
+
handle.shared.unsubscribe(handle.sub_id);
|
|
329
|
+
let _ = handle.shared.close();
|
|
330
|
+
}
|
data/honker.gemspec
CHANGED
|
@@ -21,10 +21,33 @@ Gem::Specification.new do |spec|
|
|
|
21
21
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
22
|
spec.metadata["source_code_uri"] = "https://github.com/russellromney/honker"
|
|
23
23
|
spec.metadata["documentation_uri"] = "https://honker.dev/"
|
|
24
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
# HONKER_GEM_PLATFORM is set by the release workflow when building a
|
|
27
|
+
# precompiled platform gem: it bundles the prebuilt extension next to
|
|
28
|
+
# the Ruby source in lib/honker/. Without it `gem build` produces the
|
|
29
|
+
# generic gem, which ships the Rust crate source and compiles the
|
|
30
|
+
# extension on install (see ext/honker/extconf.rb).
|
|
31
|
+
gem_platform = ENV.fetch("HONKER_GEM_PLATFORM", nil)
|
|
32
|
+
generic_gem = gem_platform.nil? || gem_platform.empty?
|
|
33
|
+
spec.platform = gem_platform unless generic_gem
|
|
34
|
+
|
|
35
|
+
extension_files = Dir.glob("lib/honker/{libhonker_ext.*,honker_ext.dll}")
|
|
36
|
+
ruby_files = Dir.glob("lib/**/*") - extension_files
|
|
37
|
+
base_files = ruby_files +
|
|
38
|
+
%w[honker.gemspec README.md LICENSE LICENSE-MIT LICENSE-APACHE]
|
|
39
|
+
|
|
40
|
+
if generic_gem
|
|
41
|
+
spec.extensions = ["ext/honker/extconf.rb"]
|
|
42
|
+
spec.files = base_files + Dir.glob("ext/**/*").select { |f| File.file?(f) }
|
|
43
|
+
else
|
|
44
|
+
spec.files = base_files + extension_files
|
|
45
|
+
end
|
|
26
46
|
spec.require_paths = ["lib"]
|
|
27
47
|
|
|
48
|
+
# CoreWatcher calls into the extension over Fiddle. Declared
|
|
49
|
+
# explicitly because fiddle stopped being a default gem in Ruby 3.5.
|
|
50
|
+
spec.add_dependency "fiddle", "~> 1.0"
|
|
28
51
|
# Honker loads the SQLite extension directly, so the Ruby sqlite3
|
|
29
52
|
# binding must expose Database#enable_load_extension/#load_extension.
|
|
30
53
|
# sqlite3 2.0.4 is the first line compatible with our Ruby >= 3.0 floor
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# lib/honker
|
|
2
|
+
|
|
3
|
+
Ruby source for the `honker` gem.
|
|
4
|
+
|
|
5
|
+
## The bundled SQLite extension
|
|
6
|
+
|
|
7
|
+
Released **platform gems** also ship the prebuilt Honker SQLite loadable
|
|
8
|
+
extension in this directory. It is not checked into the source
|
|
9
|
+
repository: it is a build artifact, gitignored, copied in at release
|
|
10
|
+
time by `scripts/copy-ruby-extension.sh` and verified by
|
|
11
|
+
`scripts/proof/check-ruby-gem.rb`.
|
|
12
|
+
|
|
13
|
+
When it is present, `Honker::Database.new` finds and loads it
|
|
14
|
+
automatically, so a platform gem needs no `extension_path:`.
|
|
15
|
+
|
|
16
|
+
| Gem platform | Bundled extension file |
|
|
17
|
+
| --------------- | ---------------------- |
|
|
18
|
+
| `x86_64-linux` | `libhonker_ext.so` |
|
|
19
|
+
| `aarch64-linux` | `libhonker_ext.so` |
|
|
20
|
+
| `arm64-darwin` | `libhonker_ext.dylib` |
|
|
21
|
+
|
|
22
|
+
The artifact name follows the target OS: `libhonker_ext.so` on Linux,
|
|
23
|
+
`libhonker_ext.dylib` on macOS, and `honker_ext.dll` on Windows.
|
|
24
|
+
|
|
25
|
+
The **generic gem** (used on every other platform, and for `github:`
|
|
26
|
+
installs) ships no prebuilt extension. Instead it bundles the Rust crate
|
|
27
|
+
source under `ext/honker/` and compiles the extension into this
|
|
28
|
+
directory on install, which needs a [Rust toolchain](https://rustup.rs).
|
data/lib/honker/scheduler.rb
CHANGED
|
@@ -79,6 +79,65 @@ module Honker
|
|
|
79
79
|
@db.db.get_first_row("SELECT honker_scheduler_soonest()")[0]
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# ---- Phase Mantle: lifecycle methods ----
|
|
83
|
+
|
|
84
|
+
UNSET = Object.new.freeze
|
|
85
|
+
private_constant :UNSET
|
|
86
|
+
|
|
87
|
+
# Pause a registered schedule. Returns true if a row was paused;
|
|
88
|
+
# false if missing or already paused. Idempotent.
|
|
89
|
+
def pause(name)
|
|
90
|
+
n = @db.db.get_first_row(
|
|
91
|
+
"SELECT honker_scheduler_pause(?)", [name],
|
|
92
|
+
)[0]
|
|
93
|
+
@db.mark_updated if n.positive?
|
|
94
|
+
n.positive?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Resume a paused schedule. Returns true if a row was resumed.
|
|
98
|
+
def resume(name)
|
|
99
|
+
n = @db.db.get_first_row(
|
|
100
|
+
"SELECT honker_scheduler_resume(?)", [name],
|
|
101
|
+
)[0]
|
|
102
|
+
@db.mark_updated if n.positive?
|
|
103
|
+
n.positive?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Return every registered schedule with current state. Each entry
|
|
107
|
+
# is a Hash with: name, queue, cron_expr, payload (JSON string),
|
|
108
|
+
# priority, expires_s, next_fire_at, enabled.
|
|
109
|
+
def list
|
|
110
|
+
raw = @db.db.get_first_row("SELECT honker_scheduler_list()")[0]
|
|
111
|
+
return [] if raw.nil? || raw.empty?
|
|
112
|
+
|
|
113
|
+
JSON.parse(raw)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Mutate fields in place. Pass only the kwargs you want changed
|
|
117
|
+
# (omitting a kwarg leaves the field alone). `payload: nil`
|
|
118
|
+
# writes JSON null. Cron change recomputes next_fire_at from now.
|
|
119
|
+
# Returns true iff a row was updated.
|
|
120
|
+
def update(name, schedule: UNSET, cron: UNSET, payload: UNSET, priority: UNSET, expires_s: UNSET)
|
|
121
|
+
expr = nil
|
|
122
|
+
expr = schedule if schedule != UNSET
|
|
123
|
+
expr = cron if expr.nil? && cron != UNSET
|
|
124
|
+
|
|
125
|
+
payload_arg = (payload == UNSET) ? nil : JSON.dump(payload)
|
|
126
|
+
priority_arg = (priority == UNSET) ? nil : priority
|
|
127
|
+
touch_expires = (expires_s == UNSET) ? 0 : 1
|
|
128
|
+
expires_arg = (expires_s == UNSET) ? nil : expires_s
|
|
129
|
+
|
|
130
|
+
any_field = !expr.nil? || payload != UNSET || priority != UNSET || expires_s != UNSET
|
|
131
|
+
return false unless any_field
|
|
132
|
+
|
|
133
|
+
n = @db.db.get_first_row(
|
|
134
|
+
"SELECT honker_scheduler_update(?, ?, ?, ?, ?, ?)",
|
|
135
|
+
[name, expr, payload_arg, priority_arg, expires_arg, touch_expires],
|
|
136
|
+
)[0]
|
|
137
|
+
@db.mark_updated if n.positive?
|
|
138
|
+
n.positive?
|
|
139
|
+
end
|
|
140
|
+
|
|
82
141
|
# Run the scheduler loop with leader election. Blocks until `stop`
|
|
83
142
|
# signals. `stop` is any object that responds to `call` (returning
|
|
84
143
|
# truthy to stop) — a common choice is a lambda backed by a Mutex-
|
data/lib/honker/version.rb
CHANGED
data/lib/honker.rb
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
require "json"
|
|
21
21
|
require "fiddle"
|
|
22
|
+
require "rbconfig"
|
|
22
23
|
require "sqlite3"
|
|
23
24
|
|
|
24
25
|
require_relative "honker/version"
|
|
@@ -26,8 +27,91 @@ require_relative "honker/transaction"
|
|
|
26
27
|
require_relative "honker/stream"
|
|
27
28
|
require_relative "honker/scheduler"
|
|
28
29
|
require_relative "honker/lock"
|
|
30
|
+
require_relative "honker/railtie" if defined?(::Rails::Railtie)
|
|
29
31
|
|
|
30
32
|
module Honker
|
|
33
|
+
# Honker's error class, raised by ExtensionResolver and CoreWatcher.
|
|
34
|
+
class Error < StandardError; end
|
|
35
|
+
|
|
36
|
+
# Resolves the path to the Honker SQLite loadable extension. Platform
|
|
37
|
+
# gems ship it bundled in lib/honker/; an explicit path and the
|
|
38
|
+
# HONKER_EXTENSION_PATH override take precedence.
|
|
39
|
+
class ExtensionResolver
|
|
40
|
+
def initialize(env: ENV.fetch("HONKER_EXTENSION_PATH", nil), bundled: nil)
|
|
41
|
+
@env = env
|
|
42
|
+
@bundled = bundled || File.expand_path("honker/#{extension_filename}", __dir__)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the extension path: an explicit `extension_path`, else
|
|
46
|
+
# HONKER_EXTENSION_PATH, else the bundled extension. Raises
|
|
47
|
+
# Honker::Error when HONKER_EXTENSION_PATH or the bundled extension
|
|
48
|
+
# is missing.
|
|
49
|
+
def resolve(extension_path = nil)
|
|
50
|
+
return extension_path unless extension_path.nil?
|
|
51
|
+
return env_extension unless env.nil? || env.empty?
|
|
52
|
+
|
|
53
|
+
path = bundled
|
|
54
|
+
return path if File.file?(path)
|
|
55
|
+
|
|
56
|
+
raise Error, "Honker SQLite extension not found at #{path}; " \
|
|
57
|
+
"set HONKER_EXTENSION_PATH or pass extension_path:"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :env, :bundled
|
|
63
|
+
|
|
64
|
+
def env_extension
|
|
65
|
+
return env if File.file?(env)
|
|
66
|
+
|
|
67
|
+
raise Error, "HONKER_EXTENSION_PATH does not exist: #{env}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extension_filename
|
|
71
|
+
case RbConfig::CONFIG.fetch("host_os")
|
|
72
|
+
when /mswin|mingw|cygwin/ then "honker_ext.dll"
|
|
73
|
+
when /darwin/ then "libhonker_ext.dylib"
|
|
74
|
+
else "libhonker_ext.so"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Resolve the bundled (or overridden) extension path without naming
|
|
80
|
+
# ExtensionResolver — useful for `database.yml` ERB and tooling.
|
|
81
|
+
def self.extension_path(override = nil)
|
|
82
|
+
ExtensionResolver.new.resolve(override)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Load the Honker extension onto a raw SQLite3::Database. Encapsulates
|
|
86
|
+
# the enable_load_extension(true)/load/enable_load_extension(false)
|
|
87
|
+
# sequence so the toggle-off can't be forgotten.
|
|
88
|
+
def self.load_extension(sqlite_conn, extension_path: nil)
|
|
89
|
+
resolved = ExtensionResolver.new.resolve(extension_path)
|
|
90
|
+
sqlite_conn.enable_load_extension(true)
|
|
91
|
+
sqlite_conn.load_extension(resolved)
|
|
92
|
+
ensure
|
|
93
|
+
sqlite_conn.enable_load_extension(false)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Run honker_bootstrap() on the connection. Idempotent. Separated from
|
|
97
|
+
# load_extension so production users can opt to bootstrap from a
|
|
98
|
+
# migration instead of every connect.
|
|
99
|
+
def self.bootstrap(sqlite_conn)
|
|
100
|
+
sqlite_conn.execute("SELECT honker_bootstrap()")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Convenience: load the extension then bootstrap. The one-liner most
|
|
104
|
+
# ORM integrations reach for.
|
|
105
|
+
def self.setup(sqlite_conn, extension_path: nil, bootstrap: true)
|
|
106
|
+
load_extension(sqlite_conn, extension_path: extension_path)
|
|
107
|
+
self.bootstrap(sqlite_conn) if bootstrap
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns a Proc suitable for Sequel/Rom/Hanami `after_connect:`.
|
|
111
|
+
def self.sequel_after_connect(extension_path: nil, bootstrap: true)
|
|
112
|
+
proc { |conn| setup(conn, extension_path: extension_path, bootstrap: bootstrap) }
|
|
113
|
+
end
|
|
114
|
+
|
|
31
115
|
class CoreWatcher
|
|
32
116
|
def initialize(db_path, extension_path, backend)
|
|
33
117
|
@lib = Fiddle.dlopen(extension_path)
|
|
@@ -86,21 +170,23 @@ module Honker
|
|
|
86
170
|
class Database
|
|
87
171
|
attr_reader :db
|
|
88
172
|
|
|
89
|
-
def initialize(path, extension_path
|
|
173
|
+
def initialize(path, extension_path: nil, watcher_backend: nil,
|
|
174
|
+
extension_resolver: ExtensionResolver.new)
|
|
90
175
|
unless watcher_backend.nil? || watcher_backend.is_a?(String)
|
|
91
176
|
raise ArgumentError, "unknown watcher backend"
|
|
92
177
|
end
|
|
93
178
|
|
|
179
|
+
resolved_extension = extension_resolver.resolve(extension_path)
|
|
94
180
|
@db = SQLite3::Database.new(path)
|
|
95
181
|
@local_update_seq = 0
|
|
96
182
|
@db.busy_timeout = 5000
|
|
97
183
|
@db.execute("PRAGMA mmap_size = 0")
|
|
98
184
|
@db.enable_load_extension(true)
|
|
99
|
-
@db.load_extension(
|
|
185
|
+
@db.load_extension(resolved_extension)
|
|
100
186
|
@db.enable_load_extension(false)
|
|
101
187
|
@db.execute_batch(DEFAULT_PRAGMAS)
|
|
102
188
|
@db.execute("SELECT honker_bootstrap()")
|
|
103
|
-
@watcher = CoreWatcher.new(path,
|
|
189
|
+
@watcher = CoreWatcher.new(path, resolved_extension, watcher_backend)
|
|
104
190
|
end
|
|
105
191
|
|
|
106
192
|
def close
|
|
@@ -348,6 +434,28 @@ module Honker
|
|
|
348
434
|
[job_id, worker_id, extend_s],
|
|
349
435
|
)[0] == 1
|
|
350
436
|
end
|
|
437
|
+
|
|
438
|
+
# Delete a pending or processing job by id. Returns true iff a row
|
|
439
|
+
# was removed. Idempotent on missing.
|
|
440
|
+
#
|
|
441
|
+
# IMPORTANT: cancel does NOT interrupt a worker currently running
|
|
442
|
+
# the handler. It invalidates the worker's claim — its next
|
|
443
|
+
# ack/heartbeat returns false. If you need the handler to actually
|
|
444
|
+
# halt, build that signal in your app.
|
|
445
|
+
def cancel(job_id)
|
|
446
|
+
n = @db.db.get_first_row("SELECT honker_cancel(?)", [job_id])[0]
|
|
447
|
+
@db.mark_updated if n.positive?
|
|
448
|
+
n.positive?
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Read a single job row by id. Returns a Hash with the row fields,
|
|
452
|
+
# or nil if the job has been ack'd, dead'd, or never existed.
|
|
453
|
+
def get_job(job_id)
|
|
454
|
+
raw = @db.db.get_first_row("SELECT honker_get_job(?)", [job_id])[0]
|
|
455
|
+
return nil if raw.nil? || raw.empty?
|
|
456
|
+
|
|
457
|
+
JSON.parse(raw)
|
|
458
|
+
end
|
|
351
459
|
end
|
|
352
460
|
|
|
353
461
|
class Outbox
|