honker 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fbf6de4cce9d9b068ecf07c59977f5ffea3295bcc55e3dc043033d8855bff648
4
+ data.tar.gz: 619580c9de45ccadb5ac20b41bea018c2ef5e11759f6d0a837d13c6239b76de4
5
+ SHA512:
6
+ metadata.gz: 3ac9490e51f9dd4edca01a4a4af3a28ec3603113e2f02bbf54d80784d1d6c6946cc8c6c70d805acb3d1df3e84ec3d9d84ff5076901c07315888e35b1de7a25f1
7
+ data.tar.gz: db21f18ef6609c2cf330428d2518b21c903fd22dd64ec8156da3ce127ab03188181142d6d5872841e3d690e8ef2cf335a3f7494a2520b9887d3d51de8c0d8801
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # honker (Ruby)
2
+
3
+ Ruby binding for [Honker](https://honker.dev) — durable queues, streams, pub/sub, and scheduler on SQLite.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ gem "honker"
9
+ ```
10
+
11
+ You'll also need the Honker SQLite extension (`libhonker.dylib` on macOS, `libhonker.so` on Linux). Prebuilds at [GitHub releases](https://github.com/russellromney/honker/releases/latest), or build:
12
+
13
+ ```bash
14
+ git clone https://github.com/russellromney/honker
15
+ cd honker
16
+ cargo build --release -p honker-extension
17
+ # → target/release/libhonker_extension.{dylib,so}
18
+ ```
19
+
20
+ ## Requirements
21
+
22
+ - Ruby 3.0+
23
+ - `sqlite3` gem ≥ 1.7 (pulled in automatically)
24
+
25
+ ## Quick start
26
+
27
+ ```ruby
28
+ require "honker"
29
+
30
+ db = Honker::Database.new("app.db", extension_path: "./libhonker_extension.dylib")
31
+ q = db.queue("emails")
32
+
33
+ # Enqueue (atomic-with-your-write via business transactions; see below)
34
+ q.enqueue({ to: "alice@example.com" })
35
+
36
+ # Claim + process + ack
37
+ job = q.claim_one("worker-1")
38
+ if job
39
+ send_email(job.payload)
40
+ job.ack
41
+ end
42
+ ```
43
+
44
+ ## API
45
+
46
+ ### `Honker::Database.new(path, extension_path:)`
47
+
48
+ Opens (or creates) a SQLite database, loads the Honker extension, applies default PRAGMAs, and bootstraps the schema.
49
+
50
+ ### `#queue(name, visibility_timeout_s: 300, max_attempts: 3)`
51
+
52
+ Handle to a named queue.
53
+
54
+ ### `Queue#enqueue(payload, delay:, run_at:, priority:, expires:)`
55
+
56
+ Inserts a job. `payload` is any JSON-serializable value. Returns the row id.
57
+
58
+ ### `Queue#claim_batch(worker_id, n)` / `Queue#claim_one(worker_id)`
59
+
60
+ Atomically claim up to N jobs (or 1).
61
+
62
+ ### `Job#ack` / `Job#retry(delay_s:, error:)` / `Job#fail(error:)` / `Job#heartbeat(extend_s:)`
63
+
64
+ Claim lifecycle. `ack` deletes. `retry` puts back with a delay (or moves to `_honker_dead` if max_attempts reached). `fail` moves to dead unconditionally. `heartbeat` extends the visibility timeout.
65
+
66
+ ### `Database#notify(channel, payload)`
67
+
68
+ Fire a `pg_notify`-style signal. Returns the notification id.
69
+
70
+ ## What's not here yet
71
+
72
+ - `listen` / WAL-based async iterator (watcher API — in progress)
73
+ - Streams (durable pub/sub with per-consumer offsets)
74
+ - Scheduler (cron-style periodic tasks)
75
+
76
+ All available via raw SQL on the same database (`db.db.execute("SELECT honker_stream_publish(...)")`). Idiomatic Ruby wrappers coming in a future release.
77
+
78
+ ## Testing
79
+
80
+ ```bash
81
+ # Build the extension
82
+ cd /path/to/honker && cargo build --release -p honker-extension
83
+
84
+ # Run the tests
85
+ cd packages/honker-ruby
86
+ bundle install
87
+ bundle exec rake test # or: ruby -Ilib -Ispec spec/honker_spec.rb
88
+ ```
data/honker.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/honker/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "honker"
7
+ spec.version = Honker::VERSION
8
+ spec.authors = ["Russell Romney"]
9
+
10
+ spec.summary = "Durable queues, streams, pub/sub, and scheduler on SQLite."
11
+ spec.description = <<~DESC.strip
12
+ Ruby binding for Honker — a SQLite-native task runtime. Queues,
13
+ streams, pub/sub, cron scheduler, results, locks, rate limits, all
14
+ in one .db file. Thin wrapper around the Honker SQLite loadable
15
+ extension; no Redis, no external broker.
16
+ DESC
17
+ spec.homepage = "https://honker.dev"
18
+ spec.license = "Apache-2.0"
19
+ spec.required_ruby_version = ">= 3.0.0"
20
+
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://github.com/russellromney/honker"
23
+ spec.metadata["documentation_uri"] = "https://honker.dev/"
24
+
25
+ spec.files = Dir.glob("lib/**/*") + %w[honker.gemspec README.md]
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_dependency "sqlite3", ">= 1.7"
29
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honker
4
+ # Advisory lock. Returned by `Database#try_lock`; `nil` means another
5
+ # owner holds it.
6
+ #
7
+ # Prefer explicit `release` — finalizers are best-effort. Holding the
8
+ # `Lock` instance does NOT guarantee you still own the lock; the
9
+ # extension's TTL can expire and a new owner take it. Use
10
+ # `heartbeat(ttl_s:)` periodically and check its return value.
11
+ class Lock
12
+ attr_reader :name, :owner
13
+
14
+ def initialize(db, name, owner)
15
+ @db = db
16
+ @name = name
17
+ @owner = owner
18
+ @released = false
19
+ # Best-effort cleanup if the caller forgets to release. We capture
20
+ # closed-over primitives (not `self`) so the finalizer can run
21
+ # without keeping the Lock itself alive.
22
+ ObjectSpace.define_finalizer(self, self.class._finalizer(db, name, owner))
23
+ end
24
+
25
+ # Class-level factory so the finalizer doesn't close over `self`.
26
+ def self._finalizer(db, name, owner)
27
+ proc do
28
+ begin
29
+ db.db.get_first_row(
30
+ "SELECT honker_lock_release(?, ?)",
31
+ [name, owner],
32
+ )
33
+ rescue StandardError
34
+ # Finalizers run after the interpreter teardown can begin;
35
+ # swallowing is the only safe option here.
36
+ end
37
+ end
38
+ end
39
+
40
+ # Release the lock. Idempotent — calling release twice is a no-op
41
+ # on the second call.
42
+ def release
43
+ return false if @released
44
+
45
+ @released = true
46
+ @db.db.get_first_row(
47
+ "SELECT honker_lock_release(?, ?)",
48
+ [@name, @owner],
49
+ )[0] == 1
50
+ end
51
+
52
+ # True if the caller has already released this handle.
53
+ def released?
54
+ @released
55
+ end
56
+
57
+ # Extend the TTL. Returns true if we still hold the lock; false if
58
+ # it was stolen (the TTL elapsed and another owner acquired it).
59
+ # The underlying SQL is the same as `try_lock`, but keyed on our
60
+ # existing `(name, owner)` pair so it refreshes rather than blocks.
61
+ def heartbeat(ttl_s:)
62
+ @db.db.get_first_row(
63
+ "SELECT honker_lock_acquire(?, ?, ?)",
64
+ [@name, @owner, ttl_s],
65
+ )[0] == 1
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Honker
6
+ # Fired on each scheduler tick that enqueued a job.
7
+ ScheduledFire = Struct.new(:name, :queue, :fire_at, :job_id) do
8
+ def self.from_row(row)
9
+ new(row["name"], row["queue"], row["fire_at"], row["job_id"])
10
+ end
11
+ end
12
+
13
+ # Cron-style scheduler. Register tasks with `add`; `tick` fires all
14
+ # boundaries that have elapsed since the last tick and enqueues the
15
+ # resulting jobs. `run(owner:, stop:)` drives the loop under a
16
+ # leader-elected advisory lock.
17
+ class Scheduler
18
+ # Lock name used for leader election in `run`. Constant so all
19
+ # processes contending for leader share a single lock row.
20
+ LEADER_LOCK = "honker-scheduler"
21
+
22
+ # TTL on the leader lock. Refreshed from `run` every HEARTBEAT_S;
23
+ # a leader whose refresh fails drops out of the loop so a standby
24
+ # can pick up without waiting the full TTL.
25
+ LOCK_TTL_S = 60
26
+
27
+ # Refresh cadence. Balance: too small and every tick is a lock
28
+ # write; too large and a standby waits longer than necessary after
29
+ # a crash. Matches the Rust binding.
30
+ HEARTBEAT_S = 20
31
+
32
+ def initialize(db)
33
+ @db = db
34
+ end
35
+
36
+ # Register a cron-scheduled task. Idempotent by `name`; registering
37
+ # the same name twice replaces the previous row.
38
+ def add(name:, queue:, cron:, payload:, priority: 0, expires_s: nil)
39
+ @db.db.get_first_row(
40
+ "SELECT honker_scheduler_register(?, ?, ?, ?, ?, ?)",
41
+ [name, queue, cron, JSON.dump(payload), priority, expires_s],
42
+ )
43
+ nil
44
+ end
45
+
46
+ # Remove a registered task by name. Returns the count deleted
47
+ # (0 or 1).
48
+ def remove(name)
49
+ @db.db.get_first_row(
50
+ "SELECT honker_scheduler_unregister(?)",
51
+ [name],
52
+ )[0]
53
+ end
54
+
55
+ # Fire all due boundaries at `now`. Returns an array of
56
+ # ScheduledFire — one per enqueued job.
57
+ def tick(now = Time.now.to_i)
58
+ rows_json = @db.db.get_first_row(
59
+ "SELECT honker_scheduler_tick(?)",
60
+ [now],
61
+ )[0]
62
+ JSON.parse(rows_json).map { |r| ScheduledFire.from_row(r) }
63
+ end
64
+
65
+ # Soonest `next_fire_at` across all tasks, or 0 if no tasks.
66
+ def soonest
67
+ @db.db.get_first_row("SELECT honker_scheduler_soonest()")[0]
68
+ end
69
+
70
+ # Run the scheduler loop with leader election. Blocks until `stop`
71
+ # signals. `stop` is any object that responds to `call` (returning
72
+ # truthy to stop) — a common choice is a lambda backed by a Mutex-
73
+ # guarded flag, or an `AtomicBoolean`-like wrapper.
74
+ #
75
+ # Only the process holding the `"honker-scheduler"` advisory lock
76
+ # fires. Standbys sleep 5s and retry. The leader heartbeats every
77
+ # 20s; if the refresh fails (returns 0), we break out of the leader
78
+ # loop immediately so we don't double-fire alongside a new leader
79
+ # that acquired the lock after our TTL elapsed.
80
+ #
81
+ # `owner` distinguishes processes — typically a hostname + pid.
82
+ # On tick error, the lock is released before re-raising so a
83
+ # standby can pick up without waiting the full TTL.
84
+ def run(owner:, stop:)
85
+ stop_fn = normalize_stop(stop)
86
+ until stop_fn.call
87
+ acquired = lock_try_acquire(LEADER_LOCK, owner, LOCK_TTL_S)
88
+ unless acquired
89
+ sleep_with_stop(5, stop_fn)
90
+ next
91
+ end
92
+
93
+ begin
94
+ leader_loop(owner, stop_fn)
95
+ ensure
96
+ lock_release(LEADER_LOCK, owner)
97
+ end
98
+ end
99
+ nil
100
+ end
101
+
102
+ private
103
+
104
+ # Convert a user-supplied stop arg into a zero-arg callable. Accept
105
+ # a proc/lambda, anything with `#call`, a `Queue` (drained = stop),
106
+ # or a mutex-guarded flag object with `#stop?`.
107
+ def normalize_stop(stop)
108
+ return stop if stop.respond_to?(:call) && stop.arity.zero?
109
+ return -> { stop.call } if stop.respond_to?(:call)
110
+ return -> { stop.stop? } if stop.respond_to?(:stop?)
111
+
112
+ raise ArgumentError,
113
+ "stop must be callable (proc/lambda) or respond to :stop?"
114
+ end
115
+
116
+ # Break sleeps into 1s increments so `stop` is honored promptly
117
+ # without busy-waiting.
118
+ def sleep_with_stop(total_s, stop_fn)
119
+ elapsed = 0
120
+ while elapsed < total_s && !stop_fn.call
121
+ sleep(1)
122
+ elapsed += 1
123
+ end
124
+ end
125
+
126
+ def leader_loop(owner, stop_fn)
127
+ last_heartbeat = monotonic_now
128
+ until stop_fn.call
129
+ # tick errors escape up to `run`, which releases the lock in
130
+ # its `ensure` before re-raising.
131
+ tick
132
+ if monotonic_now - last_heartbeat >= HEARTBEAT_S
133
+ still_ours = lock_try_acquire(LEADER_LOCK, owner, LOCK_TTL_S)
134
+ # IMPORTANT: if refresh failed, a new leader has the lock.
135
+ # Break out of the leader loop so we don't double-fire. This
136
+ # is the bug the Rust binding fixed; don't reintroduce it.
137
+ return unless still_ours
138
+
139
+ last_heartbeat = monotonic_now
140
+ end
141
+ sleep(1)
142
+ end
143
+ end
144
+
145
+ def monotonic_now
146
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
147
+ end
148
+
149
+ def lock_try_acquire(name, owner, ttl_s)
150
+ @db.db.get_first_row(
151
+ "SELECT honker_lock_acquire(?, ?, ?)",
152
+ [name, owner, ttl_s],
153
+ )[0] == 1
154
+ end
155
+
156
+ def lock_release(name, owner)
157
+ @db.db.get_first_row(
158
+ "SELECT honker_lock_release(?, ?)",
159
+ [name, owner],
160
+ )[0] == 1
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Honker
6
+ # An append-only ordered log. Publish writes an offset-stamped event;
7
+ # consumers resume from a saved offset. Mirrors the typed API in the
8
+ # Rust binding.
9
+ #
10
+ # s = db.stream("orders")
11
+ # off = s.publish({ id: 1 })
12
+ # events = s.read_since(0, 100)
13
+ # s.save_offset("billing", events.last.offset)
14
+ class Stream
15
+ attr_reader :topic
16
+
17
+ def initialize(db, topic)
18
+ @db = db
19
+ @topic = topic
20
+ end
21
+
22
+ # Publish an event. Returns the assigned offset.
23
+ def publish(payload)
24
+ publish_with_key_opt(nil, payload)
25
+ end
26
+
27
+ # Publish with a partition key — used by downstream consumers that
28
+ # want per-key ordering.
29
+ def publish_with_key(key, payload)
30
+ publish_with_key_opt(key, payload)
31
+ end
32
+
33
+ # Publish inside an open transaction. The event is visible to
34
+ # readers only after the transaction commits.
35
+ def publish_tx(tx, payload)
36
+ row = tx.query_row(
37
+ "SELECT honker_stream_publish(?, NULL, ?)",
38
+ [@topic, JSON.dump(payload)],
39
+ )
40
+ row[0]
41
+ end
42
+
43
+ # Read events with offset > `offset`, up to `limit`. Returns an
44
+ # array of StreamEvent.
45
+ def read_since(offset, limit)
46
+ rows_json = @db.db.get_first_row(
47
+ "SELECT honker_stream_read_since(?, ?, ?)",
48
+ [@topic, offset, limit],
49
+ )[0]
50
+ JSON.parse(rows_json).map { |r| StreamEvent.from_row(r) }
51
+ end
52
+
53
+ # Read events newer than this consumer's saved offset. Does NOT
54
+ # advance the offset — call `save_offset` after processing.
55
+ def read_from_consumer(consumer, limit)
56
+ read_since(get_offset(consumer), limit)
57
+ end
58
+
59
+ # Save a consumer's offset. Monotonic: saving a lower offset is
60
+ # ignored by the extension and returns false.
61
+ def save_offset(consumer, offset)
62
+ @db.db.get_first_row(
63
+ "SELECT honker_stream_save_offset(?, ?, ?)",
64
+ [consumer, @topic, offset],
65
+ )[0] == 1
66
+ end
67
+
68
+ # Save offset inside an open transaction. Gives you exactly-once
69
+ # semantics relative to whatever else ran on the same tx.
70
+ def save_offset_tx(tx, consumer, offset)
71
+ row = tx.query_row(
72
+ "SELECT honker_stream_save_offset(?, ?, ?)",
73
+ [consumer, @topic, offset],
74
+ )
75
+ row[0] == 1
76
+ end
77
+
78
+ # Current saved offset for `consumer`, or 0 if never saved.
79
+ def get_offset(consumer)
80
+ @db.db.get_first_row(
81
+ "SELECT honker_stream_get_offset(?, ?)",
82
+ [consumer, @topic],
83
+ )[0]
84
+ end
85
+
86
+ private
87
+
88
+ def publish_with_key_opt(key, payload)
89
+ @db.db.get_first_row(
90
+ "SELECT honker_stream_publish(?, ?, ?)",
91
+ [@topic, key, JSON.dump(payload)],
92
+ )[0]
93
+ end
94
+ end
95
+
96
+ # One event in a stream. `payload` is already JSON-decoded.
97
+ StreamEvent = Struct.new(:offset, :topic, :key, :payload, :created_at) do
98
+ def self.from_row(row)
99
+ new(
100
+ row["offset"],
101
+ row["topic"],
102
+ row["key"],
103
+ row["payload"].nil? ? nil : JSON.parse(row["payload"]),
104
+ row["created_at"],
105
+ )
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honker
4
+ # Wraps a SQLite transaction with helpers that thread SQL through a
5
+ # stable connection handle. Matches the shape of the Rust binding's
6
+ # `Transaction` — `execute` and `query_row` route to the underlying
7
+ # connection, which `*_tx` methods (`Queue#enqueue_tx`,
8
+ # `Stream#publish_tx`, `Stream#save_offset_tx`, `Database#notify_tx`)
9
+ # use to stay inside the open transaction.
10
+ #
11
+ # Obtain one via `Database#transaction do |tx| ... end`. The block
12
+ # auto-commits on normal return and auto-rolls-back on exception; the
13
+ # wrapping `sqlite3` gem handles the BEGIN/COMMIT/ROLLBACK for us.
14
+ # Callers may also invoke `tx.rollback!` mid-block to abort without
15
+ # raising.
16
+ class Transaction
17
+ # The raw `SQLite3::Database` underneath. Exposed as `conn` to
18
+ # mirror the Rust shape for advanced users who need `prepare` or
19
+ # other APIs not wrapped here.
20
+ attr_reader :conn
21
+
22
+ def initialize(conn)
23
+ @conn = conn
24
+ @rolled_back = false
25
+ end
26
+
27
+ # Execute a SQL statement inside the transaction.
28
+ def execute(sql, params = [])
29
+ @conn.execute(sql, params)
30
+ end
31
+
32
+ # Run a query and return the first row (or nil).
33
+ def query_row(sql, params = [])
34
+ @conn.get_first_row(sql, params)
35
+ end
36
+
37
+ # Abort the transaction early. Raises `Transaction::Rollback`
38
+ # internally so the outer sqlite3-gem block sees an exception and
39
+ # rolls back; we then swallow it at the `Database#transaction`
40
+ # boundary.
41
+ def rollback!
42
+ @rolled_back = true
43
+ raise Rollback
44
+ end
45
+
46
+ def rolled_back?
47
+ @rolled_back
48
+ end
49
+
50
+ # Sentinel to unwind the sqlite3 gem's transaction block without
51
+ # surfacing as a user-visible exception.
52
+ class Rollback < StandardError; end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honker
4
+ VERSION = "0.1.0"
5
+ end
data/lib/honker.rb ADDED
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # Ruby binding for Honker — a SQLite-native task runtime.
4
+ #
5
+ # Usage:
6
+ #
7
+ # require "honker"
8
+ #
9
+ # db = Honker::Database.new("app.db", extension_path: "./libhonker.dylib")
10
+ # q = db.queue("emails")
11
+ # q.enqueue({to: "alice@example.com"})
12
+ #
13
+ # job = q.claim_one("worker-1")
14
+ # send_email(job.payload) if job
15
+ # job&.ack
16
+ #
17
+ # Thin wrapper around the Honker SQLite loadable extension — each method
18
+ # is one SQL call via the `sqlite3` gem. No extra process, no Redis.
19
+
20
+ require "json"
21
+ require "sqlite3"
22
+
23
+ require_relative "honker/version"
24
+ require_relative "honker/transaction"
25
+ require_relative "honker/stream"
26
+ require_relative "honker/scheduler"
27
+ require_relative "honker/lock"
28
+
29
+ module Honker
30
+ DEFAULT_PRAGMAS = <<~SQL
31
+ PRAGMA journal_mode = WAL;
32
+ PRAGMA synchronous = NORMAL;
33
+ PRAGMA busy_timeout = 5000;
34
+ PRAGMA foreign_keys = ON;
35
+ PRAGMA cache_size = -32000;
36
+ PRAGMA temp_store = MEMORY;
37
+ PRAGMA wal_autocheckpoint = 10000;
38
+ SQL
39
+
40
+ # Database is a Honker handle over a SQLite file with the Honker
41
+ # extension loaded. The constructor bootstraps the schema; safe to
42
+ # open the same path from multiple processes.
43
+ class Database
44
+ attr_reader :db
45
+
46
+ def initialize(path, extension_path:)
47
+ @db = SQLite3::Database.new(path)
48
+ @db.enable_load_extension(true)
49
+ @db.load_extension(extension_path)
50
+ @db.enable_load_extension(false)
51
+ @db.execute_batch(DEFAULT_PRAGMAS)
52
+ @db.execute("SELECT honker_bootstrap()")
53
+ end
54
+
55
+ def close
56
+ @db&.close
57
+ end
58
+
59
+ # Returns a Queue handle for a named queue.
60
+ #
61
+ # visibility_timeout_s: 300 # claim expiry before reclaim
62
+ # max_attempts: 3 # retries before moving to dead
63
+ def queue(name, visibility_timeout_s: 300, max_attempts: 3)
64
+ Queue.new(
65
+ self,
66
+ name,
67
+ visibility_timeout_s: visibility_timeout_s,
68
+ max_attempts: max_attempts,
69
+ )
70
+ end
71
+
72
+ # Returns a Stream handle for an append-only ordered log.
73
+ def stream(name)
74
+ Stream.new(self, name)
75
+ end
76
+
77
+ # Returns the cron-style Scheduler facade. Cheap — no allocation
78
+ # beyond the wrapper object.
79
+ def scheduler
80
+ Scheduler.new(self)
81
+ end
82
+
83
+ # Fire a pg_notify-style pub/sub signal. Returns the notification id.
84
+ def notify(channel, payload)
85
+ row = @db.get_first_row("SELECT notify(?, ?)", [channel, JSON.dump(payload)])
86
+ row[0]
87
+ end
88
+
89
+ # Fire a notification inside an open transaction. The signal lands
90
+ # only when the transaction commits.
91
+ def notify_tx(tx, channel, payload)
92
+ row = tx.query_row(
93
+ "SELECT notify(?, ?)",
94
+ [channel, JSON.dump(payload)],
95
+ )
96
+ row[0]
97
+ end
98
+
99
+ # Run a block inside a SQLite transaction. The block receives a
100
+ # Honker::Transaction; returning normally commits, raising rolls
101
+ # back, and `tx.rollback!` rolls back without surfacing an error.
102
+ #
103
+ # db.transaction do |tx|
104
+ # tx.execute("INSERT INTO orders ...")
105
+ # q.enqueue_tx(tx, {order_id: 1})
106
+ # end
107
+ def transaction
108
+ tx = Transaction.new(@db)
109
+ begin
110
+ @db.transaction do
111
+ yield tx
112
+ end
113
+ rescue Transaction::Rollback
114
+ # Caller used tx.rollback! to abort. The block exited with an
115
+ # exception so the sqlite3 gem already rolled back — just
116
+ # swallow the sentinel.
117
+ nil
118
+ end
119
+ end
120
+
121
+ # Try to acquire an advisory lock. Returns a `Lock` handle on
122
+ # success, `nil` if another owner holds it.
123
+ def try_lock(name, owner:, ttl_s:)
124
+ acquired = @db.get_first_row(
125
+ "SELECT honker_lock_acquire(?, ?, ?)",
126
+ [name, owner, ttl_s],
127
+ )[0]
128
+ return nil unless acquired == 1
129
+
130
+ Lock.new(self, name, owner)
131
+ end
132
+
133
+ # Fixed-window rate limiter. Returns true if this call fits within
134
+ # `limit` requests per `per` seconds.
135
+ def try_rate_limit(name, limit:, per:)
136
+ @db.get_first_row(
137
+ "SELECT honker_rate_limit_try(?, ?, ?)",
138
+ [name, limit, per],
139
+ )[0] == 1
140
+ end
141
+
142
+ # Sweep old rate-limit window rows. Returns count deleted.
143
+ def sweep_rate_limits(older_than_s:)
144
+ @db.get_first_row(
145
+ "SELECT honker_rate_limit_sweep(?)",
146
+ [older_than_s],
147
+ )[0]
148
+ end
149
+
150
+ # Persist a job result for later retrieval via `get_result`.
151
+ # `value` is stored verbatim — JSON-encode it yourself if you want
152
+ # to round-trip structured data.
153
+ def save_result(job_id, value, ttl_s:)
154
+ @db.get_first_row(
155
+ "SELECT honker_result_save(?, ?, ?)",
156
+ [job_id, value, ttl_s],
157
+ )
158
+ nil
159
+ end
160
+
161
+ # Fetch a stored result, or nil if absent or expired.
162
+ def get_result(job_id)
163
+ @db.get_first_row(
164
+ "SELECT honker_result_get(?)",
165
+ [job_id],
166
+ )[0]
167
+ end
168
+
169
+ # Drop expired result rows. Returns count swept.
170
+ def sweep_results
171
+ @db.get_first_row("SELECT honker_result_sweep()")[0]
172
+ end
173
+
174
+ # Delete notifications older than `older_than_s` seconds. Returns
175
+ # the number of rows deleted.
176
+ def prune_notifications(older_than_s:)
177
+ @db.execute(
178
+ "DELETE FROM _honker_notifications WHERE created_at < unixepoch() - ?",
179
+ [older_than_s],
180
+ )
181
+ @db.changes
182
+ end
183
+ end
184
+
185
+ class Queue
186
+ attr_reader :name, :max_attempts
187
+
188
+ def initialize(db, name, visibility_timeout_s:, max_attempts:)
189
+ @db = db
190
+ @name = name
191
+ @visibility_timeout_s = visibility_timeout_s
192
+ @max_attempts = max_attempts
193
+ end
194
+
195
+ # Enqueue a job. Returns the inserted row id.
196
+ #
197
+ # q.enqueue({to: "alice"}, delay: 60, priority: 10, expires: 3600)
198
+ def enqueue(payload, delay: nil, run_at: nil, priority: 0, expires: nil)
199
+ row = @db.db.get_first_row(
200
+ "SELECT honker_enqueue(?, ?, ?, ?, ?, ?, ?)",
201
+ [@name, JSON.dump(payload), run_at, delay, priority, @max_attempts, expires],
202
+ )
203
+ row[0]
204
+ end
205
+
206
+ # Enqueue inside an open transaction. Atomic with whatever else ran
207
+ # on the same tx.
208
+ def enqueue_tx(tx, payload, delay: nil, run_at: nil, priority: 0, expires: nil)
209
+ row = tx.query_row(
210
+ "SELECT honker_enqueue(?, ?, ?, ?, ?, ?, ?)",
211
+ [@name, JSON.dump(payload), run_at, delay, priority, @max_attempts, expires],
212
+ )
213
+ row[0]
214
+ end
215
+
216
+ # Claim up to n jobs atomically. Returns an array of Job.
217
+ def claim_batch(worker_id, n)
218
+ rows_json = @db.db.get_first_row(
219
+ "SELECT honker_claim_batch(?, ?, ?, ?)",
220
+ [@name, worker_id, n, @visibility_timeout_s],
221
+ )[0]
222
+ JSON.parse(rows_json).map { |r| Job.new(self, r) }
223
+ end
224
+
225
+ # Claim a single job or nil if the queue is empty.
226
+ def claim_one(worker_id)
227
+ claim_batch(worker_id, 1).first
228
+ end
229
+
230
+ # Ack multiple jobs in one transaction. Returns the number acked.
231
+ def ack_batch(ids, worker_id)
232
+ @db.db.get_first_row(
233
+ "SELECT honker_ack_batch(?, ?)",
234
+ [JSON.dump(ids), worker_id],
235
+ )[0]
236
+ end
237
+
238
+ # Sweep this queue's expired claims back to pending. Returns the
239
+ # number of rows reclaimed.
240
+ def sweep_expired
241
+ @db.db.get_first_row(
242
+ "SELECT honker_sweep_expired(?)",
243
+ [@name],
244
+ )[0]
245
+ end
246
+
247
+ # Internal: invoked by Job#ack.
248
+ def _ack(job_id, worker_id)
249
+ @db.db.get_first_row("SELECT honker_ack(?, ?)", [job_id, worker_id])[0] == 1
250
+ end
251
+
252
+ # Internal: invoked by Job#retry.
253
+ def _retry(job_id, worker_id, delay_s, err_msg)
254
+ @db.db.get_first_row(
255
+ "SELECT honker_retry(?, ?, ?, ?)",
256
+ [job_id, worker_id, delay_s, err_msg],
257
+ )[0] == 1
258
+ end
259
+
260
+ # Internal: invoked by Job#fail.
261
+ def _fail(job_id, worker_id, err_msg)
262
+ @db.db.get_first_row(
263
+ "SELECT honker_fail(?, ?, ?)",
264
+ [job_id, worker_id, err_msg],
265
+ )[0] == 1
266
+ end
267
+
268
+ # Internal: invoked by Job#heartbeat.
269
+ def _heartbeat(job_id, worker_id, extend_s)
270
+ @db.db.get_first_row(
271
+ "SELECT honker_heartbeat(?, ?, ?)",
272
+ [job_id, worker_id, extend_s],
273
+ )[0] == 1
274
+ end
275
+ end
276
+
277
+ # A claimed unit of work. `payload` is the decoded JSON value (Hash,
278
+ # Array, etc.). `id`, `worker_id`, and `attempts` are metadata from
279
+ # the claim result.
280
+ class Job
281
+ attr_reader :id, :queue_name, :payload, :worker_id, :attempts
282
+
283
+ def initialize(queue, row)
284
+ @queue = queue
285
+ @id = row["id"]
286
+ @queue_name = row["queue"]
287
+ @payload = JSON.parse(row["payload"]) unless row["payload"].nil?
288
+ @worker_id = row["worker_id"]
289
+ @attempts = row["attempts"]
290
+ end
291
+
292
+ # DELETEs the row if the claim is still valid. Returns true/false.
293
+ def ack
294
+ @queue._ack(@id, @worker_id)
295
+ end
296
+
297
+ # Returns the job to pending with a delay, or moves it to dead
298
+ # after max_attempts. Returns true iff the claim was valid.
299
+ def retry(delay_s: 60, error: "")
300
+ @queue._retry(@id, @worker_id, delay_s, error)
301
+ end
302
+
303
+ # Unconditionally moves the claim to dead.
304
+ def fail(error: "")
305
+ @queue._fail(@id, @worker_id, error)
306
+ end
307
+
308
+ # Extend the claim's visibility timeout.
309
+ def heartbeat(extend_s:)
310
+ @queue._heartbeat(@id, @worker_id, extend_s)
311
+ end
312
+ end
313
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: honker
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Russell Romney
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sqlite3
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.7'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.7'
26
+ description: |-
27
+ Ruby binding for Honker — a SQLite-native task runtime. Queues,
28
+ streams, pub/sub, cron scheduler, results, locks, rate limits, all
29
+ in one .db file. Thin wrapper around the Honker SQLite loadable
30
+ extension; no Redis, no external broker.
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - README.md
36
+ - honker.gemspec
37
+ - lib/honker.rb
38
+ - lib/honker/lock.rb
39
+ - lib/honker/scheduler.rb
40
+ - lib/honker/stream.rb
41
+ - lib/honker/transaction.rb
42
+ - lib/honker/version.rb
43
+ homepage: https://honker.dev
44
+ licenses:
45
+ - Apache-2.0
46
+ metadata:
47
+ homepage_uri: https://honker.dev
48
+ source_code_uri: https://github.com/russellromney/honker
49
+ documentation_uri: https://honker.dev/
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 3.0.0
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 4.0.10
65
+ specification_version: 4
66
+ summary: Durable queues, streams, pub/sub, and scheduler on SQLite.
67
+ test_files: []