honker 0.1.1 → 0.3.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.
data/lib/honker.rb CHANGED
@@ -18,6 +18,8 @@
18
18
  # is one SQL call via the `sqlite3` gem. No extra process, no Redis.
19
19
 
20
20
  require "json"
21
+ require "fiddle"
22
+ require "rbconfig"
21
23
  require "sqlite3"
22
24
 
23
25
  require_relative "honker/version"
@@ -25,12 +27,137 @@ require_relative "honker/transaction"
25
27
  require_relative "honker/stream"
26
28
  require_relative "honker/scheduler"
27
29
  require_relative "honker/lock"
30
+ require_relative "honker/railtie" if defined?(::Rails::Railtie)
28
31
 
29
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
+
30
156
  DEFAULT_PRAGMAS = <<~SQL
31
157
  PRAGMA journal_mode = WAL;
32
158
  PRAGMA synchronous = NORMAL;
33
159
  PRAGMA busy_timeout = 5000;
160
+ PRAGMA mmap_size = 0;
34
161
  PRAGMA foreign_keys = ON;
35
162
  PRAGMA cache_size = -32000;
36
163
  PRAGMA temp_store = MEMORY;
@@ -43,17 +170,27 @@ module Honker
43
170
  class Database
44
171
  attr_reader :db
45
172
 
46
- def initialize(path, extension_path:)
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)
47
180
  @db = SQLite3::Database.new(path)
48
181
  @local_update_seq = 0
182
+ @db.busy_timeout = 5000
183
+ @db.execute("PRAGMA mmap_size = 0")
49
184
  @db.enable_load_extension(true)
50
- @db.load_extension(extension_path)
185
+ @db.load_extension(resolved_extension)
51
186
  @db.enable_load_extension(false)
52
187
  @db.execute_batch(DEFAULT_PRAGMAS)
53
188
  @db.execute("SELECT honker_bootstrap()")
189
+ @watcher = CoreWatcher.new(path, resolved_extension, watcher_backend)
54
190
  end
55
191
 
56
192
  def close
193
+ @watcher&.close
57
194
  @db&.close
58
195
  end
59
196
 
@@ -65,6 +202,10 @@ module Honker
65
202
  @local_update_seq
66
203
  end
67
204
 
205
+ def wait_for_update(timeout_s)
206
+ @watcher.wait(timeout_s)
207
+ end
208
+
68
209
  # Returns a Queue handle for a named queue.
69
210
  #
70
211
  # visibility_timeout_s: 300 # claim expiry before reclaim
@@ -78,6 +219,18 @@ module Honker
78
219
  )
79
220
  end
80
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
+
81
234
  # Returns a Stream handle for an append-only ordered log.
82
235
  def stream(name)
83
236
  Stream.new(self, name)
@@ -281,6 +434,87 @@ module Honker
281
434
  [job_id, worker_id, extend_s],
282
435
  )[0] == 1
283
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
284
518
  end
285
519
 
286
520
  # A claimed unit of work. `payload` is the decoded JSON value (Hash,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: honker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Russell Romney
@@ -9,44 +9,89 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
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'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: sqlite3
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
30
  - - ">="
17
31
  - !ruby/object:Gem::Version
18
- version: '1.7'
32
+ version: 2.0.4
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '3'
19
36
  type: :runtime
20
37
  prerelease: false
21
38
  version_requirements: !ruby/object:Gem::Requirement
22
39
  requirements:
23
40
  - - ">="
24
41
  - !ruby/object:Gem::Version
25
- version: '1.7'
42
+ version: 2.0.4
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '3'
26
46
  description: |-
27
47
  Ruby binding for Honker — a SQLite-native task runtime. Queues,
28
48
  streams, pub/sub, time-trigger scheduler, results, locks, rate limits, all
29
49
  in one .db file. Thin wrapper around the Honker SQLite loadable
30
50
  extension; no Redis, no external broker.
31
51
  executables: []
32
- extensions: []
52
+ extensions:
53
+ - ext/honker/extconf.rb
33
54
  extra_rdoc_files: []
34
55
  files:
56
+ - LICENSE
57
+ - LICENSE-APACHE
58
+ - LICENSE-MIT
35
59
  - README.md
60
+ - ext/honker/extconf.rb
61
+ - ext/honker/honker-core/Cargo.toml
62
+ - ext/honker/honker-core/LICENSE
63
+ - ext/honker/honker-core/LICENSE-APACHE
64
+ - ext/honker/honker-core/LICENSE-MIT
65
+ - ext/honker/honker-core/README.md
66
+ - ext/honker/honker-core/src/cron.rs
67
+ - ext/honker/honker-core/src/honker_ops.rs
68
+ - ext/honker/honker-core/src/kernel_watcher.rs
69
+ - ext/honker/honker-core/src/lib.rs
70
+ - ext/honker/honker-core/src/shm_watcher.rs
71
+ - ext/honker/honker-extension/Cargo.toml
72
+ - ext/honker/honker-extension/LICENSE
73
+ - ext/honker/honker-extension/LICENSE-APACHE
74
+ - ext/honker/honker-extension/LICENSE-MIT
75
+ - ext/honker/honker-extension/README.md
76
+ - ext/honker/honker-extension/src/lib.rs
36
77
  - honker.gemspec
37
78
  - lib/honker.rb
79
+ - lib/honker/README.md
38
80
  - lib/honker/lock.rb
81
+ - lib/honker/railtie.rb
39
82
  - lib/honker/scheduler.rb
40
83
  - lib/honker/stream.rb
41
84
  - lib/honker/transaction.rb
42
85
  - lib/honker/version.rb
43
86
  homepage: https://honker.dev
44
87
  licenses:
88
+ - MIT
45
89
  - Apache-2.0
46
90
  metadata:
47
91
  homepage_uri: https://honker.dev
48
92
  source_code_uri: https://github.com/russellromney/honker
49
93
  documentation_uri: https://honker.dev/
94
+ rubygems_mfa_required: 'true'
50
95
  rdoc_options: []
51
96
  require_paths:
52
97
  - lib
@@ -61,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
106
  - !ruby/object:Gem::Version
62
107
  version: '0'
63
108
  requirements: []
64
- rubygems_version: 4.0.10
109
+ rubygems_version: 3.6.9
65
110
  specification_version: 4
66
111
  summary: Durable queues, streams, pub/sub, and scheduler on SQLite.
67
112
  test_files: []