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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Honker
4
+ VERSION = "0.3.0"
5
+ end