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
data/lib/honker.rb
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
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 "fiddle"
|
|
22
|
+
require "rbconfig"
|
|
23
|
+
require "sqlite3"
|
|
24
|
+
|
|
25
|
+
require_relative "honker/version"
|
|
26
|
+
require_relative "honker/transaction"
|
|
27
|
+
require_relative "honker/stream"
|
|
28
|
+
require_relative "honker/scheduler"
|
|
29
|
+
require_relative "honker/lock"
|
|
30
|
+
require_relative "honker/railtie" if defined?(::Rails::Railtie)
|
|
31
|
+
|
|
32
|
+
module Honker
|
|
33
|
+
# Honker's error class, raised by ExtensionResolver and CoreWatcher.
|
|
34
|
+
class Error < StandardError; end
|
|
35
|
+
|
|
36
|
+
# Resolves the path to the Honker SQLite loadable extension. Platform
|
|
37
|
+
# gems ship it bundled in lib/honker/; an explicit path and the
|
|
38
|
+
# HONKER_EXTENSION_PATH override take precedence.
|
|
39
|
+
class ExtensionResolver
|
|
40
|
+
def initialize(env: ENV.fetch("HONKER_EXTENSION_PATH", nil), bundled: nil)
|
|
41
|
+
@env = env
|
|
42
|
+
@bundled = bundled || File.expand_path("honker/#{extension_filename}", __dir__)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the extension path: an explicit `extension_path`, else
|
|
46
|
+
# HONKER_EXTENSION_PATH, else the bundled extension. Raises
|
|
47
|
+
# Honker::Error when HONKER_EXTENSION_PATH or the bundled extension
|
|
48
|
+
# is missing.
|
|
49
|
+
def resolve(extension_path = nil)
|
|
50
|
+
return extension_path unless extension_path.nil?
|
|
51
|
+
return env_extension unless env.nil? || env.empty?
|
|
52
|
+
|
|
53
|
+
path = bundled
|
|
54
|
+
return path if File.file?(path)
|
|
55
|
+
|
|
56
|
+
raise Error, "Honker SQLite extension not found at #{path}; " \
|
|
57
|
+
"set HONKER_EXTENSION_PATH or pass extension_path:"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :env, :bundled
|
|
63
|
+
|
|
64
|
+
def env_extension
|
|
65
|
+
return env if File.file?(env)
|
|
66
|
+
|
|
67
|
+
raise Error, "HONKER_EXTENSION_PATH does not exist: #{env}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extension_filename
|
|
71
|
+
case RbConfig::CONFIG.fetch("host_os")
|
|
72
|
+
when /mswin|mingw|cygwin/ then "honker_ext.dll"
|
|
73
|
+
when /darwin/ then "libhonker_ext.dylib"
|
|
74
|
+
else "libhonker_ext.so"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Resolve the bundled (or overridden) extension path without naming
|
|
80
|
+
# ExtensionResolver — useful for `database.yml` ERB and tooling.
|
|
81
|
+
def self.extension_path(override = nil)
|
|
82
|
+
ExtensionResolver.new.resolve(override)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Load the Honker extension onto a raw SQLite3::Database. Encapsulates
|
|
86
|
+
# the enable_load_extension(true)/load/enable_load_extension(false)
|
|
87
|
+
# sequence so the toggle-off can't be forgotten.
|
|
88
|
+
def self.load_extension(sqlite_conn, extension_path: nil)
|
|
89
|
+
resolved = ExtensionResolver.new.resolve(extension_path)
|
|
90
|
+
sqlite_conn.enable_load_extension(true)
|
|
91
|
+
sqlite_conn.load_extension(resolved)
|
|
92
|
+
ensure
|
|
93
|
+
sqlite_conn.enable_load_extension(false)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Run honker_bootstrap() on the connection. Idempotent. Separated from
|
|
97
|
+
# load_extension so production users can opt to bootstrap from a
|
|
98
|
+
# migration instead of every connect.
|
|
99
|
+
def self.bootstrap(sqlite_conn)
|
|
100
|
+
sqlite_conn.execute("SELECT honker_bootstrap()")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Convenience: load the extension then bootstrap. The one-liner most
|
|
104
|
+
# ORM integrations reach for.
|
|
105
|
+
def self.setup(sqlite_conn, extension_path: nil, bootstrap: true)
|
|
106
|
+
load_extension(sqlite_conn, extension_path: extension_path)
|
|
107
|
+
self.bootstrap(sqlite_conn) if bootstrap
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns a Proc suitable for Sequel/Rom/Hanami `after_connect:`.
|
|
111
|
+
def self.sequel_after_connect(extension_path: nil, bootstrap: true)
|
|
112
|
+
proc { |conn| setup(conn, extension_path: extension_path, bootstrap: bootstrap) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class CoreWatcher
|
|
116
|
+
def initialize(db_path, extension_path, backend)
|
|
117
|
+
@lib = Fiddle.dlopen(extension_path)
|
|
118
|
+
@open = Fiddle::Function.new(
|
|
119
|
+
@lib["honker_watcher_open"],
|
|
120
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
|
|
121
|
+
Fiddle::TYPE_VOIDP,
|
|
122
|
+
)
|
|
123
|
+
@wait = Fiddle::Function.new(
|
|
124
|
+
@lib["honker_watcher_wait"],
|
|
125
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG_LONG],
|
|
126
|
+
Fiddle::TYPE_INT,
|
|
127
|
+
)
|
|
128
|
+
@close = Fiddle::Function.new(
|
|
129
|
+
@lib["honker_watcher_close"],
|
|
130
|
+
[Fiddle::TYPE_VOIDP],
|
|
131
|
+
Fiddle::TYPE_VOID,
|
|
132
|
+
)
|
|
133
|
+
err = "\0" * 1024
|
|
134
|
+
@handle = @open.call(db_path.to_s, backend.to_s, err, err.bytesize)
|
|
135
|
+
return unless @handle.to_i.zero?
|
|
136
|
+
|
|
137
|
+
raise ArgumentError, err.delete_suffix("\0").split("\0", 2).first
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def wait(timeout_s)
|
|
141
|
+
code = @wait.call(@handle, (timeout_s * 1000).ceil)
|
|
142
|
+
return true if code == 1
|
|
143
|
+
return false if code == 0
|
|
144
|
+
|
|
145
|
+
raise Error, "honker update watcher closed or died"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def close
|
|
149
|
+
return if @handle.nil? || @handle.to_i.zero?
|
|
150
|
+
|
|
151
|
+
@close.call(@handle)
|
|
152
|
+
@handle = nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
DEFAULT_PRAGMAS = <<~SQL
|
|
157
|
+
PRAGMA journal_mode = WAL;
|
|
158
|
+
PRAGMA synchronous = NORMAL;
|
|
159
|
+
PRAGMA busy_timeout = 5000;
|
|
160
|
+
PRAGMA mmap_size = 0;
|
|
161
|
+
PRAGMA foreign_keys = ON;
|
|
162
|
+
PRAGMA cache_size = -32000;
|
|
163
|
+
PRAGMA temp_store = MEMORY;
|
|
164
|
+
PRAGMA wal_autocheckpoint = 10000;
|
|
165
|
+
SQL
|
|
166
|
+
|
|
167
|
+
# Database is a Honker handle over a SQLite file with the Honker
|
|
168
|
+
# extension loaded. The constructor bootstraps the schema; safe to
|
|
169
|
+
# open the same path from multiple processes.
|
|
170
|
+
class Database
|
|
171
|
+
attr_reader :db
|
|
172
|
+
|
|
173
|
+
def initialize(path, extension_path: nil, watcher_backend: nil,
|
|
174
|
+
extension_resolver: ExtensionResolver.new)
|
|
175
|
+
unless watcher_backend.nil? || watcher_backend.is_a?(String)
|
|
176
|
+
raise ArgumentError, "unknown watcher backend"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
resolved_extension = extension_resolver.resolve(extension_path)
|
|
180
|
+
@db = SQLite3::Database.new(path)
|
|
181
|
+
@local_update_seq = 0
|
|
182
|
+
@db.busy_timeout = 5000
|
|
183
|
+
@db.execute("PRAGMA mmap_size = 0")
|
|
184
|
+
@db.enable_load_extension(true)
|
|
185
|
+
@db.load_extension(resolved_extension)
|
|
186
|
+
@db.enable_load_extension(false)
|
|
187
|
+
@db.execute_batch(DEFAULT_PRAGMAS)
|
|
188
|
+
@db.execute("SELECT honker_bootstrap()")
|
|
189
|
+
@watcher = CoreWatcher.new(path, resolved_extension, watcher_backend)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def close
|
|
193
|
+
@watcher&.close
|
|
194
|
+
@db&.close
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def mark_updated
|
|
198
|
+
@local_update_seq += 1
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def update_snapshot
|
|
202
|
+
@local_update_seq
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def wait_for_update(timeout_s)
|
|
206
|
+
@watcher.wait(timeout_s)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Returns a Queue handle for a named queue.
|
|
210
|
+
#
|
|
211
|
+
# visibility_timeout_s: 300 # claim expiry before reclaim
|
|
212
|
+
# max_attempts: 3 # retries before moving to dead
|
|
213
|
+
def queue(name, visibility_timeout_s: 300, max_attempts: 3)
|
|
214
|
+
Queue.new(
|
|
215
|
+
self,
|
|
216
|
+
name,
|
|
217
|
+
visibility_timeout_s: visibility_timeout_s,
|
|
218
|
+
max_attempts: max_attempts,
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Transactional side-effect delivery built on a reserved queue.
|
|
223
|
+
def outbox(name, delivery, visibility_timeout_s: 60, max_attempts: 5, base_backoff_s: 5)
|
|
224
|
+
Outbox.new(
|
|
225
|
+
self,
|
|
226
|
+
name,
|
|
227
|
+
delivery,
|
|
228
|
+
visibility_timeout_s: visibility_timeout_s,
|
|
229
|
+
max_attempts: max_attempts,
|
|
230
|
+
base_backoff_s: base_backoff_s,
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Returns a Stream handle for an append-only ordered log.
|
|
235
|
+
def stream(name)
|
|
236
|
+
Stream.new(self, name)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Returns the time-trigger Scheduler facade. Cheap — no allocation
|
|
240
|
+
# beyond the wrapper object.
|
|
241
|
+
def scheduler
|
|
242
|
+
Scheduler.new(self)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Fire a pg_notify-style pub/sub signal. Returns the notification id.
|
|
246
|
+
def notify(channel, payload)
|
|
247
|
+
row = @db.get_first_row("SELECT notify(?, ?)", [channel, JSON.dump(payload)])
|
|
248
|
+
row[0]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Fire a notification inside an open transaction. The signal lands
|
|
252
|
+
# only when the transaction commits.
|
|
253
|
+
def notify_tx(tx, channel, payload)
|
|
254
|
+
row = tx.query_row(
|
|
255
|
+
"SELECT notify(?, ?)",
|
|
256
|
+
[channel, JSON.dump(payload)],
|
|
257
|
+
)
|
|
258
|
+
row[0]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Run a block inside a SQLite transaction. The block receives a
|
|
262
|
+
# Honker::Transaction; returning normally commits, raising rolls
|
|
263
|
+
# back, and `tx.rollback!` rolls back without surfacing an error.
|
|
264
|
+
#
|
|
265
|
+
# db.transaction do |tx|
|
|
266
|
+
# tx.execute("INSERT INTO orders ...")
|
|
267
|
+
# q.enqueue_tx(tx, {order_id: 1})
|
|
268
|
+
# end
|
|
269
|
+
def transaction
|
|
270
|
+
tx = Transaction.new(@db)
|
|
271
|
+
begin
|
|
272
|
+
@db.transaction do
|
|
273
|
+
yield tx
|
|
274
|
+
end
|
|
275
|
+
rescue Transaction::Rollback
|
|
276
|
+
# Caller used tx.rollback! to abort. The block exited with an
|
|
277
|
+
# exception so the sqlite3 gem already rolled back — just
|
|
278
|
+
# swallow the sentinel.
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Try to acquire an advisory lock. Returns a `Lock` handle on
|
|
284
|
+
# success, `nil` if another owner holds it.
|
|
285
|
+
def try_lock(name, owner:, ttl_s:)
|
|
286
|
+
acquired = @db.get_first_row(
|
|
287
|
+
"SELECT honker_lock_acquire(?, ?, ?)",
|
|
288
|
+
[name, owner, ttl_s],
|
|
289
|
+
)[0]
|
|
290
|
+
return nil unless acquired == 1
|
|
291
|
+
|
|
292
|
+
Lock.new(self, name, owner)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Fixed-window rate limiter. Returns true if this call fits within
|
|
296
|
+
# `limit` requests per `per` seconds.
|
|
297
|
+
def try_rate_limit(name, limit:, per:)
|
|
298
|
+
@db.get_first_row(
|
|
299
|
+
"SELECT honker_rate_limit_try(?, ?, ?)",
|
|
300
|
+
[name, limit, per],
|
|
301
|
+
)[0] == 1
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Sweep old rate-limit window rows. Returns count deleted.
|
|
305
|
+
def sweep_rate_limits(older_than_s:)
|
|
306
|
+
@db.get_first_row(
|
|
307
|
+
"SELECT honker_rate_limit_sweep(?)",
|
|
308
|
+
[older_than_s],
|
|
309
|
+
)[0]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Persist a job result for later retrieval via `get_result`.
|
|
313
|
+
# `value` is stored verbatim — JSON-encode it yourself if you want
|
|
314
|
+
# to round-trip structured data.
|
|
315
|
+
def save_result(job_id, value, ttl_s:)
|
|
316
|
+
@db.get_first_row(
|
|
317
|
+
"SELECT honker_result_save(?, ?, ?)",
|
|
318
|
+
[job_id, value, ttl_s],
|
|
319
|
+
)
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Fetch a stored result, or nil if absent or expired.
|
|
324
|
+
def get_result(job_id)
|
|
325
|
+
@db.get_first_row(
|
|
326
|
+
"SELECT honker_result_get(?)",
|
|
327
|
+
[job_id],
|
|
328
|
+
)[0]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Drop expired result rows. Returns count swept.
|
|
332
|
+
def sweep_results
|
|
333
|
+
@db.get_first_row("SELECT honker_result_sweep()")[0]
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Delete notifications older than `older_than_s` seconds. Returns
|
|
337
|
+
# the number of rows deleted.
|
|
338
|
+
def prune_notifications(older_than_s:)
|
|
339
|
+
@db.execute(
|
|
340
|
+
"DELETE FROM _honker_notifications WHERE created_at < unixepoch() - ?",
|
|
341
|
+
[older_than_s],
|
|
342
|
+
)
|
|
343
|
+
@db.changes
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
class Queue
|
|
348
|
+
attr_reader :name, :max_attempts
|
|
349
|
+
|
|
350
|
+
def initialize(db, name, visibility_timeout_s:, max_attempts:)
|
|
351
|
+
@db = db
|
|
352
|
+
@name = name
|
|
353
|
+
@visibility_timeout_s = visibility_timeout_s
|
|
354
|
+
@max_attempts = max_attempts
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Enqueue a job. Returns the inserted row id.
|
|
358
|
+
#
|
|
359
|
+
# q.enqueue({to: "alice"}, delay: 60, priority: 10, expires: 3600)
|
|
360
|
+
def enqueue(payload, delay: nil, run_at: nil, priority: 0, expires: nil)
|
|
361
|
+
row = @db.db.get_first_row(
|
|
362
|
+
"SELECT honker_enqueue(?, ?, ?, ?, ?, ?, ?)",
|
|
363
|
+
[@name, JSON.dump(payload), run_at, delay, priority, @max_attempts, expires],
|
|
364
|
+
)
|
|
365
|
+
row[0]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Enqueue inside an open transaction. Atomic with whatever else ran
|
|
369
|
+
# on the same tx.
|
|
370
|
+
def enqueue_tx(tx, payload, delay: nil, run_at: nil, priority: 0, expires: nil)
|
|
371
|
+
row = tx.query_row(
|
|
372
|
+
"SELECT honker_enqueue(?, ?, ?, ?, ?, ?, ?)",
|
|
373
|
+
[@name, JSON.dump(payload), run_at, delay, priority, @max_attempts, expires],
|
|
374
|
+
)
|
|
375
|
+
row[0]
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Claim up to n jobs atomically. Returns an array of Job.
|
|
379
|
+
def claim_batch(worker_id, n)
|
|
380
|
+
rows_json = @db.db.get_first_row(
|
|
381
|
+
"SELECT honker_claim_batch(?, ?, ?, ?)",
|
|
382
|
+
[@name, worker_id, n, @visibility_timeout_s],
|
|
383
|
+
)[0]
|
|
384
|
+
JSON.parse(rows_json).map { |r| Job.new(self, r) }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Claim a single job or nil if the queue is empty.
|
|
388
|
+
def claim_one(worker_id)
|
|
389
|
+
claim_batch(worker_id, 1).first
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Ack multiple jobs in one transaction. Returns the number acked.
|
|
393
|
+
def ack_batch(ids, worker_id)
|
|
394
|
+
@db.db.get_first_row(
|
|
395
|
+
"SELECT honker_ack_batch(?, ?)",
|
|
396
|
+
[JSON.dump(ids), worker_id],
|
|
397
|
+
)[0]
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Sweep this queue's expired claims back to pending. Returns the
|
|
401
|
+
# number of rows reclaimed.
|
|
402
|
+
def sweep_expired
|
|
403
|
+
@db.db.get_first_row(
|
|
404
|
+
"SELECT honker_sweep_expired(?)",
|
|
405
|
+
[@name],
|
|
406
|
+
)[0]
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Internal: invoked by Job#ack.
|
|
410
|
+
def _ack(job_id, worker_id)
|
|
411
|
+
@db.db.get_first_row("SELECT honker_ack(?, ?)", [job_id, worker_id])[0] == 1
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Internal: invoked by Job#retry.
|
|
415
|
+
def _retry(job_id, worker_id, delay_s, err_msg)
|
|
416
|
+
@db.db.get_first_row(
|
|
417
|
+
"SELECT honker_retry(?, ?, ?, ?)",
|
|
418
|
+
[job_id, worker_id, delay_s, err_msg],
|
|
419
|
+
)[0] == 1
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Internal: invoked by Job#fail.
|
|
423
|
+
def _fail(job_id, worker_id, err_msg)
|
|
424
|
+
@db.db.get_first_row(
|
|
425
|
+
"SELECT honker_fail(?, ?, ?)",
|
|
426
|
+
[job_id, worker_id, err_msg],
|
|
427
|
+
)[0] == 1
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Internal: invoked by Job#heartbeat.
|
|
431
|
+
def _heartbeat(job_id, worker_id, extend_s)
|
|
432
|
+
@db.db.get_first_row(
|
|
433
|
+
"SELECT honker_heartbeat(?, ?, ?)",
|
|
434
|
+
[job_id, worker_id, extend_s],
|
|
435
|
+
)[0] == 1
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Delete a pending or processing job by id. Returns true iff a row
|
|
439
|
+
# was removed. Idempotent on missing.
|
|
440
|
+
#
|
|
441
|
+
# IMPORTANT: cancel does NOT interrupt a worker currently running
|
|
442
|
+
# the handler. It invalidates the worker's claim — its next
|
|
443
|
+
# ack/heartbeat returns false. If you need the handler to actually
|
|
444
|
+
# halt, build that signal in your app.
|
|
445
|
+
def cancel(job_id)
|
|
446
|
+
n = @db.db.get_first_row("SELECT honker_cancel(?)", [job_id])[0]
|
|
447
|
+
@db.mark_updated if n.positive?
|
|
448
|
+
n.positive?
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Read a single job row by id. Returns a Hash with the row fields,
|
|
452
|
+
# or nil if the job has been ack'd, dead'd, or never existed.
|
|
453
|
+
def get_job(job_id)
|
|
454
|
+
raw = @db.db.get_first_row("SELECT honker_get_job(?)", [job_id])[0]
|
|
455
|
+
return nil if raw.nil? || raw.empty?
|
|
456
|
+
|
|
457
|
+
JSON.parse(raw)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
class Outbox
|
|
462
|
+
attr_reader :name, :queue, :max_attempts, :base_backoff_s
|
|
463
|
+
|
|
464
|
+
def initialize(db, name, delivery, visibility_timeout_s:, max_attempts:, base_backoff_s:)
|
|
465
|
+
raise ArgumentError, "delivery must respond to #call" unless delivery.respond_to?(:call)
|
|
466
|
+
|
|
467
|
+
@name = name
|
|
468
|
+
@delivery = delivery
|
|
469
|
+
@max_attempts = max_attempts
|
|
470
|
+
@base_backoff_s = base_backoff_s
|
|
471
|
+
@queue = db.queue(
|
|
472
|
+
"_outbox:#{name}",
|
|
473
|
+
visibility_timeout_s: visibility_timeout_s,
|
|
474
|
+
max_attempts: max_attempts,
|
|
475
|
+
)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def enqueue(payload, tx: nil, delay: nil, run_at: nil, priority: 0, expires: nil)
|
|
479
|
+
if tx
|
|
480
|
+
@queue.enqueue_tx(tx, payload, delay: delay, run_at: run_at, priority: priority, expires: expires)
|
|
481
|
+
else
|
|
482
|
+
@queue.enqueue(payload, delay: delay, run_at: run_at, priority: priority, expires: expires)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def run_once(worker_id)
|
|
487
|
+
job = @queue.claim_one(worker_id)
|
|
488
|
+
return false unless job
|
|
489
|
+
|
|
490
|
+
begin
|
|
491
|
+
if @delivery.arity == 1
|
|
492
|
+
@delivery.call(job.payload)
|
|
493
|
+
else
|
|
494
|
+
@delivery.call(job.payload, job)
|
|
495
|
+
end
|
|
496
|
+
raise "outbox ack failed for job #{job.id}" unless job.ack
|
|
497
|
+
rescue StandardError => e
|
|
498
|
+
delay_s = retry_delay(job.attempts)
|
|
499
|
+
raise "outbox retry failed for job #{job.id}" unless job.retry(delay_s: delay_s, error: "#{e}\n#{e.backtrace&.join("\n")}")
|
|
500
|
+
end
|
|
501
|
+
true
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def run_worker(worker_id, stop: nil, idle_sleep_s: 0.1)
|
|
505
|
+
until stop&.call
|
|
506
|
+
processed = run_once(worker_id)
|
|
507
|
+
sleep(idle_sleep_s) unless processed
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
private
|
|
512
|
+
|
|
513
|
+
def retry_delay(attempts)
|
|
514
|
+
return 0 if @base_backoff_s <= 0
|
|
515
|
+
|
|
516
|
+
(@base_backoff_s * (2**[attempts - 1, 0].max)).ceil
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# A claimed unit of work. `payload` is the decoded JSON value (Hash,
|
|
521
|
+
# Array, etc.). `id`, `worker_id`, and `attempts` are metadata from
|
|
522
|
+
# the claim result.
|
|
523
|
+
class Job
|
|
524
|
+
attr_reader :id, :queue_name, :payload, :worker_id, :attempts
|
|
525
|
+
|
|
526
|
+
def initialize(queue, row)
|
|
527
|
+
@queue = queue
|
|
528
|
+
@id = row["id"]
|
|
529
|
+
@queue_name = row["queue"]
|
|
530
|
+
@payload = JSON.parse(row["payload"]) unless row["payload"].nil?
|
|
531
|
+
@worker_id = row["worker_id"]
|
|
532
|
+
@attempts = row["attempts"]
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# DELETEs the row if the claim is still valid. Returns true/false.
|
|
536
|
+
def ack
|
|
537
|
+
@queue._ack(@id, @worker_id)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Returns the job to pending with a delay, or moves it to dead
|
|
541
|
+
# after max_attempts. Returns true iff the claim was valid.
|
|
542
|
+
def retry(delay_s: 60, error: "")
|
|
543
|
+
@queue._retry(@id, @worker_id, delay_s, error)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Unconditionally moves the claim to dead.
|
|
547
|
+
def fail(error: "")
|
|
548
|
+
@queue._fail(@id, @worker_id, error)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Extend the claim's visibility timeout.
|
|
552
|
+
def heartbeat(extend_s:)
|
|
553
|
+
@queue._heartbeat(@id, @worker_id, extend_s)
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: honker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.0
|
|
5
|
+
platform: aarch64-linux
|
|
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: fiddle
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sqlite3
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 2.0.4
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '3'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: 2.0.4
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3'
|
|
46
|
+
description: |-
|
|
47
|
+
Ruby binding for Honker — a SQLite-native task runtime. Queues,
|
|
48
|
+
streams, pub/sub, time-trigger scheduler, results, locks, rate limits, all
|
|
49
|
+
in one .db file. Thin wrapper around the Honker SQLite loadable
|
|
50
|
+
extension; no Redis, no external broker.
|
|
51
|
+
executables: []
|
|
52
|
+
extensions: []
|
|
53
|
+
extra_rdoc_files: []
|
|
54
|
+
files:
|
|
55
|
+
- LICENSE
|
|
56
|
+
- LICENSE-APACHE
|
|
57
|
+
- LICENSE-MIT
|
|
58
|
+
- README.md
|
|
59
|
+
- honker.gemspec
|
|
60
|
+
- lib/honker.rb
|
|
61
|
+
- lib/honker/README.md
|
|
62
|
+
- lib/honker/libhonker_ext.so
|
|
63
|
+
- lib/honker/lock.rb
|
|
64
|
+
- lib/honker/railtie.rb
|
|
65
|
+
- lib/honker/scheduler.rb
|
|
66
|
+
- lib/honker/stream.rb
|
|
67
|
+
- lib/honker/transaction.rb
|
|
68
|
+
- lib/honker/version.rb
|
|
69
|
+
homepage: https://honker.dev
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
- Apache-2.0
|
|
73
|
+
metadata:
|
|
74
|
+
homepage_uri: https://honker.dev
|
|
75
|
+
source_code_uri: https://github.com/russellromney/honker
|
|
76
|
+
documentation_uri: https://honker.dev/
|
|
77
|
+
rubygems_mfa_required: 'true'
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: 3.0.0
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 3.6.9
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: Durable queues, streams, pub/sub, and scheduler on SQLite.
|
|
95
|
+
test_files: []
|