honker 0.1.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,392 @@
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
+ watcher_poll_interval_ms: Option<u64>,
73
+ ) -> std::result::Result<HonkerWatcherHandle, String> {
74
+ let backend = honker_core::WatcherBackend::parse(backend.filter(|s| !s.is_empty()))?;
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
+ }
80
+ let shared = Arc::new(honker_core::SharedUpdateWatcher::new_with_config(
81
+ PathBuf::from(db_path),
82
+ config,
83
+ ));
84
+ let (sub_id, rx) = shared.subscribe();
85
+ Ok(HonkerWatcherHandle { shared, sub_id, rx })
86
+ }
87
+
88
+ fn attach_watcher_sql_functions(conn: &Connection) -> Result<()> {
89
+ conn.create_scalar_function(
90
+ "honker_update_watcher_open",
91
+ 2,
92
+ FunctionFlags::SQLITE_UTF8,
93
+ |ctx| {
94
+ let db_path: String = ctx.get(0)?;
95
+ let backend: Option<String> = ctx.get(1)?;
96
+ let handle = open_watcher_handle(&db_path, backend.as_deref(), None).map_err(|e| {
97
+ rusqlite::Error::UserFunctionError(Box::new(std::io::Error::other(e)))
98
+ })?;
99
+ let id = NEXT_SQL_WATCHER_ID.fetch_add(1, Ordering::Relaxed);
100
+ SQL_WATCHERS.lock().unwrap().insert(id, handle);
101
+ Ok(id as i64)
102
+ },
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
+ )?;
122
+ conn.create_scalar_function(
123
+ "honker_update_watcher_wait",
124
+ 2,
125
+ FunctionFlags::SQLITE_UTF8,
126
+ |ctx| {
127
+ let id: i64 = ctx.get(0)?;
128
+ let timeout_ms: i64 = ctx.get(1)?;
129
+ let Some(handle) = SQL_WATCHERS.lock().unwrap().remove(&(id as u64)) else {
130
+ return Ok(-1);
131
+ };
132
+ let timeout_ms = timeout_ms.max(0) as u64;
133
+ let code = match handle.rx.recv_timeout(Duration::from_millis(timeout_ms)) {
134
+ Ok(()) => 1,
135
+ Err(RecvTimeoutError::Timeout) => 0,
136
+ Err(RecvTimeoutError::Disconnected) => -1,
137
+ };
138
+ if code != -1 {
139
+ SQL_WATCHERS.lock().unwrap().insert(id as u64, handle);
140
+ } else {
141
+ handle.shared.unsubscribe(handle.sub_id);
142
+ let _ = handle.shared.close();
143
+ }
144
+ Ok(code)
145
+ },
146
+ )?;
147
+ conn.create_scalar_function(
148
+ "honker_update_watcher_close",
149
+ 1,
150
+ FunctionFlags::SQLITE_UTF8,
151
+ |ctx| {
152
+ let id: i64 = ctx.get(0)?;
153
+ if let Some(handle) = SQL_WATCHERS.lock().unwrap().remove(&(id as u64)) {
154
+ handle.shared.unsubscribe(handle.sub_id);
155
+ let _ = handle.shared.close();
156
+ }
157
+ Ok(1)
158
+ },
159
+ )?;
160
+ Ok(())
161
+ }
162
+
163
+ unsafe fn set_error_msg(
164
+ pz_err_msg: *mut *mut c_char,
165
+ p_api: *mut ffi::sqlite3_api_routines,
166
+ message: &str,
167
+ ) {
168
+ if pz_err_msg.is_null() || p_api.is_null() {
169
+ return;
170
+ }
171
+ let Some(malloc) = (unsafe { (*p_api).malloc }) else {
172
+ return;
173
+ };
174
+ let len = match message.len().checked_add(1) {
175
+ Some(len) if c_int::try_from(len).is_ok() => len,
176
+ _ => return,
177
+ };
178
+ let ptr = unsafe { malloc(len as c_int) }.cast::<c_char>();
179
+ if ptr.is_null() {
180
+ return;
181
+ }
182
+ unsafe {
183
+ ptr::copy_nonoverlapping(message.as_ptr().cast::<c_char>(), ptr, message.len());
184
+ *ptr.add(message.len()) = 0;
185
+ *pz_err_msg = ptr;
186
+ }
187
+ }
188
+
189
+ unsafe fn extension_init2(
190
+ db: *mut ffi::sqlite3,
191
+ pz_err_msg: *mut *mut c_char,
192
+ p_api: *mut ffi::sqlite3_api_routines,
193
+ ) -> c_int {
194
+ if p_api.is_null() {
195
+ return ffi::SQLITE_ERROR;
196
+ }
197
+ let result = unsafe { ffi::rusqlite_extension_init2(p_api) }
198
+ .map_err(Error::from)
199
+ .and_then(|()| unsafe { Connection::from_handle(db) })
200
+ .and_then(extension_init);
201
+ match result {
202
+ Ok(true) => ffi::SQLITE_OK_LOAD_PERMANENTLY,
203
+ Ok(false) => ffi::SQLITE_OK,
204
+ Err(err) => {
205
+ unsafe { set_error_msg(pz_err_msg, p_api, &err.to_string()) };
206
+ ffi::SQLITE_ERROR
207
+ }
208
+ }
209
+ }
210
+
211
+ /// SQLite entry point. Name must match `sqlite3_<extname>_init`; SQLite
212
+ /// derives `<extname>` from the filename — stripping the `lib` prefix
213
+ /// and any non-alphabetic characters:
214
+ /// `libhonker_ext.dylib` -> `honker_ext` -> `honkerext`
215
+ /// -> `sqlite3_honkerext_init`.
216
+ ///
217
+ /// # Safety
218
+ /// Called by SQLite. All pointers are SQLite-owned.
219
+ #[unsafe(no_mangle)]
220
+ pub unsafe extern "C" fn sqlite3_honkerext_init(
221
+ db: *mut ffi::sqlite3,
222
+ pz_err_msg: *mut *mut c_char,
223
+ p_api: *mut ffi::sqlite3_api_routines,
224
+ ) -> c_int {
225
+ match catch_unwind(AssertUnwindSafe(|| unsafe {
226
+ extension_init2(db, pz_err_msg, p_api)
227
+ })) {
228
+ Ok(code) => code,
229
+ Err(payload) => {
230
+ let err = panic_error(payload);
231
+ unsafe { set_error_msg(pz_err_msg, p_api, &err.to_string()) };
232
+ ffi::SQLITE_ERROR
233
+ }
234
+ }
235
+ }
236
+
237
+ pub struct HonkerWatcherHandle {
238
+ shared: Arc<honker_core::SharedUpdateWatcher>,
239
+ sub_id: u64,
240
+ rx: Receiver<()>,
241
+ }
242
+
243
+ unsafe fn cstr_to_string(ptr: *const c_char) -> std::result::Result<Option<String>, String> {
244
+ if ptr.is_null() {
245
+ return Ok(None);
246
+ }
247
+ let s = unsafe { CStr::from_ptr(ptr) }
248
+ .to_str()
249
+ .map_err(|e| format!("invalid UTF-8: {e}"))?;
250
+ if s.is_empty() {
251
+ Ok(None)
252
+ } else {
253
+ Ok(Some(s.to_string()))
254
+ }
255
+ }
256
+
257
+ unsafe fn write_error(buf: *mut c_char, len: usize, message: &str) {
258
+ if buf.is_null() || len == 0 {
259
+ return;
260
+ }
261
+ let bytes = message.as_bytes();
262
+ let copy_len = bytes.len().min(len.saturating_sub(1));
263
+ unsafe {
264
+ ptr::copy_nonoverlapping(bytes.as_ptr().cast::<c_char>(), buf, copy_len);
265
+ *buf.add(copy_len) = 0;
266
+ }
267
+ }
268
+
269
+ /// Open a core-backed update watcher over `db_path`.
270
+ ///
271
+ /// Returns null on error and writes a NUL-terminated diagnostic into
272
+ /// `err_buf` when provided. `backend` accepts the same exact aliases as
273
+ /// `honker_core::WatcherBackend::parse`; null / empty means polling.
274
+ ///
275
+ /// # Safety
276
+ /// All pointers must be valid NUL-terminated strings when non-null.
277
+ #[unsafe(no_mangle)]
278
+ pub unsafe extern "C" fn honker_watcher_open(
279
+ db_path: *const c_char,
280
+ backend: *const c_char,
281
+ err_buf: *mut c_char,
282
+ err_buf_len: usize,
283
+ ) -> *mut HonkerWatcherHandle {
284
+ match catch_unwind(AssertUnwindSafe(|| {
285
+ if db_path.is_null() {
286
+ return Err("db_path is null".to_string());
287
+ }
288
+ let path = unsafe { CStr::from_ptr(db_path) }
289
+ .to_str()
290
+ .map_err(|e| format!("invalid db_path UTF-8: {e}"))?;
291
+ let backend = unsafe { cstr_to_string(backend) }?;
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))?;
332
+ Ok(Box::into_raw(Box::new(handle)))
333
+ })) {
334
+ Ok(Ok(ptr)) => ptr,
335
+ Ok(Err(err)) => {
336
+ unsafe { write_error(err_buf, err_buf_len, &err) };
337
+ ptr::null_mut()
338
+ }
339
+ Err(payload) => {
340
+ let err = panic_error(payload).to_string();
341
+ unsafe { write_error(err_buf, err_buf_len, &err) };
342
+ ptr::null_mut()
343
+ }
344
+ }
345
+ }
346
+
347
+ /// Wait for the next database update.
348
+ ///
349
+ /// Returns:
350
+ /// * `1` when an update was observed
351
+ /// * `0` on timeout
352
+ /// * `-1` when the watcher/subscription has closed or died
353
+ /// * `-2` if this function catches an internal panic
354
+ ///
355
+ /// # Safety
356
+ /// `handle` must be a pointer returned by `honker_watcher_open` and not
357
+ /// yet passed to `honker_watcher_close`.
358
+ #[unsafe(no_mangle)]
359
+ pub unsafe extern "C" fn honker_watcher_wait(
360
+ handle: *mut HonkerWatcherHandle,
361
+ timeout_ms: u64,
362
+ ) -> c_int {
363
+ if handle.is_null() {
364
+ return -1;
365
+ }
366
+ match catch_unwind(AssertUnwindSafe(|| {
367
+ let handle = unsafe { &mut *handle };
368
+ match handle.rx.recv_timeout(Duration::from_millis(timeout_ms)) {
369
+ Ok(()) => 1,
370
+ Err(RecvTimeoutError::Timeout) => 0,
371
+ Err(RecvTimeoutError::Disconnected) => -1,
372
+ }
373
+ })) {
374
+ Ok(code) => code,
375
+ Err(_) => -2,
376
+ }
377
+ }
378
+
379
+ /// Close a watcher opened by `honker_watcher_open`.
380
+ ///
381
+ /// # Safety
382
+ /// `handle` must be null or a pointer returned by `honker_watcher_open`.
383
+ /// Passing the same non-null pointer twice is undefined behavior.
384
+ #[unsafe(no_mangle)]
385
+ pub unsafe extern "C" fn honker_watcher_close(handle: *mut HonkerWatcherHandle) {
386
+ if handle.is_null() {
387
+ return;
388
+ }
389
+ let handle = unsafe { Box::from_raw(handle) };
390
+ handle.shared.unsubscribe(handle.sub_id);
391
+ let _ = handle.shared.close();
392
+ }
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.1"
5
5
  end