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.
@@ -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
- spec.files = Dir.glob("lib/**/*") + %w[honker.gemspec README.md LICENSE LICENSE-MIT LICENSE-APACHE]
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).
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Honker
6
+ class Railtie < ::Rails::Railtie
7
+ config.after_initialize do
8
+ Honker.bootstrap(ActiveRecord::Base.connection)
9
+ end
10
+ end
11
+ end
@@ -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-
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honker
4
- VERSION = "0.1.2"
4
+ VERSION = "0.3.0"
5
5
  end
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:, watcher_backend: nil)
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(extension_path)
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, extension_path, watcher_backend)
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