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.
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: []