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 +7 -0
- data/README.md +88 -0
- data/honker.gemspec +29 -0
- data/lib/honker/lock.rb +68 -0
- data/lib/honker/scheduler.rb +163 -0
- data/lib/honker/stream.rb +108 -0
- data/lib/honker/transaction.rb +54 -0
- data/lib/honker/version.rb +5 -0
- data/lib/honker.rb +313 -0
- metadata +67 -0
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
|
data/lib/honker/lock.rb
ADDED
|
@@ -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
|
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: []
|