honker 0.3.0-aarch64-linux
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/LICENSE +6 -0
- data/LICENSE-APACHE +176 -0
- data/LICENSE-MIT +21 -0
- data/README.md +252 -0
- data/honker.gemspec +56 -0
- data/lib/honker/README.md +28 -0
- data/lib/honker/libhonker_ext.so +0 -0
- data/lib/honker/lock.rb +68 -0
- data/lib/honker/railtie.rb +11 -0
- data/lib/honker/scheduler.rb +249 -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 +556 -0
- metadata +95 -0
|
@@ -0,0 +1,249 @@
|
|
|
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
|
+
# Time-trigger 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
|
+
UPDATE_POLL_S = 0.05
|
|
32
|
+
|
|
33
|
+
def initialize(db)
|
|
34
|
+
@db = db
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Register a scheduled task. `cron:` is kept for backward
|
|
38
|
+
# compatibility; `schedule:` is the clearer name and can hold:
|
|
39
|
+
#
|
|
40
|
+
# - 5-field cron
|
|
41
|
+
# - 6-field cron
|
|
42
|
+
# - `@every <n><unit>` like `@every 1s`
|
|
43
|
+
#
|
|
44
|
+
# Idempotent by `name`; registering the same name twice replaces
|
|
45
|
+
# the previous row.
|
|
46
|
+
def add(name:, queue:, cron: nil, schedule: nil, payload:, priority: 0, expires_s: nil)
|
|
47
|
+
expr = schedule || cron
|
|
48
|
+
raise ArgumentError, "must provide cron: or schedule:" if expr.nil? || expr.empty?
|
|
49
|
+
|
|
50
|
+
@db.db.get_first_row(
|
|
51
|
+
"SELECT honker_scheduler_register(?, ?, ?, ?, ?, ?)",
|
|
52
|
+
[name, queue, expr, JSON.dump(payload), priority, expires_s],
|
|
53
|
+
)
|
|
54
|
+
@db.mark_updated
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Remove a registered task by name. Returns the count deleted
|
|
59
|
+
# (0 or 1).
|
|
60
|
+
def remove(name)
|
|
61
|
+
@db.db.get_first_row(
|
|
62
|
+
"SELECT honker_scheduler_unregister(?)",
|
|
63
|
+
[name],
|
|
64
|
+
)[0].tap { @db.mark_updated }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Fire all due boundaries at `now`. Returns an array of
|
|
68
|
+
# ScheduledFire — one per enqueued job.
|
|
69
|
+
def tick(now = Time.now.to_i)
|
|
70
|
+
rows_json = @db.db.get_first_row(
|
|
71
|
+
"SELECT honker_scheduler_tick(?)",
|
|
72
|
+
[now],
|
|
73
|
+
)[0]
|
|
74
|
+
JSON.parse(rows_json).map { |r| ScheduledFire.from_row(r) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Soonest `next_fire_at` across all tasks, or 0 if no tasks.
|
|
78
|
+
def soonest
|
|
79
|
+
@db.db.get_first_row("SELECT honker_scheduler_soonest()")[0]
|
|
80
|
+
end
|
|
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
|
+
|
|
141
|
+
# Run the scheduler loop with leader election. Blocks until `stop`
|
|
142
|
+
# signals. `stop` is any object that responds to `call` (returning
|
|
143
|
+
# truthy to stop) — a common choice is a lambda backed by a Mutex-
|
|
144
|
+
# guarded flag, or an `AtomicBoolean`-like wrapper.
|
|
145
|
+
#
|
|
146
|
+
# Only the process holding the `"honker-scheduler"` advisory lock
|
|
147
|
+
# fires. Standbys sleep 5s and retry. The leader heartbeats every
|
|
148
|
+
# 20s; if the refresh fails (returns 0), we break out of the leader
|
|
149
|
+
# loop immediately so we don't double-fire alongside a new leader
|
|
150
|
+
# that acquired the lock after our TTL elapsed.
|
|
151
|
+
#
|
|
152
|
+
# `owner` distinguishes processes — typically a hostname + pid.
|
|
153
|
+
# On tick error, the lock is released before re-raising so a
|
|
154
|
+
# standby can pick up without waiting the full TTL.
|
|
155
|
+
def run(owner:, stop:)
|
|
156
|
+
stop_fn = normalize_stop(stop)
|
|
157
|
+
until stop_fn.call
|
|
158
|
+
acquired = lock_try_acquire(LEADER_LOCK, owner, LOCK_TTL_S)
|
|
159
|
+
unless acquired
|
|
160
|
+
wait_for_update_or_timeout(5, stop_fn)
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
begin
|
|
165
|
+
leader_loop(owner, stop_fn)
|
|
166
|
+
ensure
|
|
167
|
+
lock_release(LEADER_LOCK, owner)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# Convert a user-supplied stop arg into a zero-arg callable. Accept
|
|
176
|
+
# a proc/lambda, anything with `#call`, a `Queue` (drained = stop),
|
|
177
|
+
# or a mutex-guarded flag object with `#stop?`.
|
|
178
|
+
def normalize_stop(stop)
|
|
179
|
+
return stop if stop.respond_to?(:call) && stop.arity.zero?
|
|
180
|
+
return -> { stop.call } if stop.respond_to?(:call)
|
|
181
|
+
return -> { stop.stop? } if stop.respond_to?(:stop?)
|
|
182
|
+
|
|
183
|
+
raise ArgumentError,
|
|
184
|
+
"stop must be callable (proc/lambda) or respond to :stop?"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def leader_loop(owner, stop_fn)
|
|
188
|
+
last_heartbeat = monotonic_now
|
|
189
|
+
until stop_fn.call
|
|
190
|
+
# tick errors escape up to `run`, which releases the lock in
|
|
191
|
+
# its `ensure` before re-raising.
|
|
192
|
+
tick
|
|
193
|
+
if monotonic_now - last_heartbeat >= HEARTBEAT_S
|
|
194
|
+
still_ours = lock_try_acquire(LEADER_LOCK, owner, LOCK_TTL_S)
|
|
195
|
+
# IMPORTANT: if refresh failed, a new leader has the lock.
|
|
196
|
+
# Break out of the leader loop so we don't double-fire. This
|
|
197
|
+
# is the bug the Rust binding fixed; don't reintroduce it.
|
|
198
|
+
return unless still_ours
|
|
199
|
+
|
|
200
|
+
last_heartbeat = monotonic_now
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
wait_s = HEARTBEAT_S - (monotonic_now - last_heartbeat)
|
|
204
|
+
wait_s = 0 if wait_s.negative?
|
|
205
|
+
|
|
206
|
+
next_fire = soonest
|
|
207
|
+
if next_fire.positive?
|
|
208
|
+
until_next = next_fire - Time.now.to_i
|
|
209
|
+
until_next = 0 if until_next.negative?
|
|
210
|
+
wait_s = [wait_s, until_next].min
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
wait_for_update_or_timeout(wait_s, stop_fn)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def monotonic_now
|
|
218
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def wait_for_update_or_timeout(total_s, stop_fn)
|
|
222
|
+
return if total_s <= 0
|
|
223
|
+
|
|
224
|
+
deadline = monotonic_now + total_s
|
|
225
|
+
|
|
226
|
+
until stop_fn.call
|
|
227
|
+
now = monotonic_now
|
|
228
|
+
break if now >= deadline
|
|
229
|
+
|
|
230
|
+
slice = [0.1, deadline - now].min
|
|
231
|
+
return if @db.wait_for_update(slice)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def lock_try_acquire(name, owner, ttl_s)
|
|
236
|
+
@db.db.get_first_row(
|
|
237
|
+
"SELECT honker_lock_acquire(?, ?, ?)",
|
|
238
|
+
[name, owner, ttl_s],
|
|
239
|
+
)[0] == 1
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def lock_release(name, owner)
|
|
243
|
+
@db.db.get_first_row(
|
|
244
|
+
"SELECT honker_lock_release(?, ?)",
|
|
245
|
+
[name, owner],
|
|
246
|
+
)[0] == 1
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
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
|