honker 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 26a3d929e2e2266567868784cc70e65fe06a60b8e93956f9d65721f39b4ed5c0
4
- data.tar.gz: 41d9411865cb1d0774d3fd3f4147606b508980ac66f39f7cda339a71e6ca9981
3
+ metadata.gz: 4186dca28701dc5b7f2654da008e49ba84c51218a3d7141d6ff5c5d842ef3875
4
+ data.tar.gz: 12e5f86f35bc2832405fa98a7c60ca0667b9577ab2041ce571893b7596f46613
5
5
  SHA512:
6
- metadata.gz: c20a4106adbe01b12b380991cf3dea7ffb6fdf175d1f0aa430ef0cf7abd029d755535109c67bf69315cc7060d0e335dee45534fa4ea3858ae27daebabda8a10f
7
- data.tar.gz: ec076836d653243e5a50204943263f0dfecb8e664704b95f28c3edc14644ba6a7051d8cc73a163c90486f5a9e778e7dc002b0528858369b018dc979a7f78b915
6
+ metadata.gz: c0efdcaf60dac27bc103ccc09bf367e16a8b55b41001355ac230fdab14361d2d1a8c31a768bbba53a3065bcee1280760ea5cf30d95d72428af709ab5e554b3ad
7
+ data.tar.gz: 245e0ed000e237c10670e4822252a004bf3cf1897476696cbeee97a9bbd3eddf0c4ebc494fd172f24b99f9a86d52307fb121380e15fff35ae17e790d9ddf151a
data/README.md CHANGED
@@ -230,6 +230,19 @@ if (job = q.claim_one("worker-1"))
230
230
  end
231
231
  ```
232
232
 
233
+ Live pub/sub listeners are channel-filtered and skip notifications that
234
+ already existed when they attached:
235
+
236
+ ```ruby
237
+ db.listen("orders") do |notification|
238
+ puts notification.payload
239
+ end
240
+ ```
241
+
242
+ Without a block, `listen` returns an enumerable listener. Use
243
+ `listener.next(timeout_s: 5)` for a bounded wait and call `listener.close`
244
+ when finished. Closing the database also closes every listener created from it.
245
+
233
246
  Delayed jobs use `run_at:`:
234
247
 
235
248
  ```ruby
@@ -3441,9 +3441,14 @@ while True:
3441
3441
  let _ = std::fs::remove_file(format!("{}-wal", tmp.display()));
3442
3442
  let _ = std::fs::remove_file(format!("{}-shm", tmp.display()));
3443
3443
 
3444
+ // 300 ms = 6x RX_POLL_MS. The regression this guards is recv_timeout
3445
+ // blocking on the safety-net interval, which lands at 500 ms or more,
3446
+ // so this still catches it with room to spare. The old 150 ms bound
3447
+ // was below CI scheduler jitter, not below the bug: it failed a
3448
+ // Windows runner at 155 ms while the real failure is 3x further out.
3444
3449
  assert!(
3445
- elapsed < Duration::from_millis(150),
3446
- "kernel watcher shutdown took {elapsed:?}, expected < 150 ms \
3450
+ elapsed < Duration::from_millis(300),
3451
+ "kernel watcher shutdown took {elapsed:?}, expected < 300 ms \
3447
3452
  (RX_POLL_MS = 50 ms; if this exceeds 500 ms the recv_timeout \
3448
3453
  is blocking on the safety-net interval again)"
3449
3454
  );
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Honker
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/honker.rb CHANGED
@@ -159,6 +159,295 @@ module Honker
159
159
  end
160
160
  end
161
161
 
162
+ # Fans one native watcher out to any number of Ruby waiters. The native
163
+ # watcher owns a single receiver, so callers must not race each other on
164
+ # CoreWatcher#wait directly.
165
+ class UpdateHub
166
+ WAIT_SLICE_S = 0.1
167
+
168
+ def initialize(watcher)
169
+ @watcher = watcher
170
+ @mutex = Mutex.new
171
+ @changed = ConditionVariable.new
172
+ @generation = 0
173
+ @seen_by_thread = {}
174
+ @stopping = false
175
+ @closed = false
176
+ @disposed = false
177
+ @error = nil
178
+ @thread = Thread.new { run }
179
+ @thread.name = "honker-update-hub" if @thread.respond_to?(:name=)
180
+ end
181
+
182
+ def snapshot
183
+ @mutex.synchronize { @generation }
184
+ end
185
+
186
+ def closed?
187
+ @mutex.synchronize { @closed }
188
+ end
189
+
190
+ def signal
191
+ @mutex.synchronize do
192
+ return if @closed
193
+
194
+ @generation += 1
195
+ @changed.broadcast
196
+ end
197
+ end
198
+
199
+ # Wake blocked waiters without claiming that SQLite changed. Listener
200
+ # cancellation uses this path so closing one listener cannot create a
201
+ # spurious Database#wait_for_update result for unrelated callers.
202
+ def wake
203
+ @mutex.synchronize { @changed.broadcast unless @closed }
204
+ end
205
+
206
+ def wait_after(generation, timeout_s = nil, &cancelled)
207
+ deadline = timeout_s.nil? ? nil : monotonic_now + [timeout_s.to_f, 0.0].max
208
+ @mutex.synchronize do
209
+ loop do
210
+ return false if cancelled&.call
211
+ raise @error if @error
212
+ return false if @closed
213
+ return true if @generation > generation
214
+
215
+ if deadline
216
+ remaining = deadline - monotonic_now
217
+ return false if remaining <= 0
218
+
219
+ @changed.wait(@mutex, remaining)
220
+ else
221
+ @changed.wait(@mutex)
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ def wait(timeout_s)
228
+ deadline = monotonic_now + [timeout_s.to_f, 0.0].max
229
+ key = Thread.current
230
+ @mutex.synchronize do
231
+ @seen_by_thread.delete_if { |thread, _generation| !thread.alive? }
232
+ loop do
233
+ raise @error if @error
234
+ return false if @closed
235
+ if @generation > @seen_by_thread.fetch(key, 0)
236
+ @seen_by_thread[key] = @generation
237
+ return true
238
+ end
239
+
240
+ remaining = deadline - monotonic_now
241
+ return false if remaining <= 0
242
+
243
+ @changed.wait(@mutex, remaining)
244
+ end
245
+ end
246
+ end
247
+
248
+ def close
249
+ @mutex.synchronize do
250
+ return if @disposed
251
+
252
+ if @stopping
253
+ @changed.wait(@mutex) until @disposed
254
+ return
255
+ end
256
+
257
+ @stopping = true
258
+ end
259
+ begin
260
+ @thread.join
261
+ @watcher.close
262
+ ensure
263
+ @mutex.synchronize do
264
+ @disposed = true
265
+ @closed = true
266
+ @changed.broadcast
267
+ end
268
+ end
269
+ end
270
+
271
+ private
272
+
273
+ def run
274
+ loop do
275
+ break if @mutex.synchronize { @stopping }
276
+
277
+ signal if @watcher.wait(WAIT_SLICE_S)
278
+ end
279
+ rescue StandardError => e
280
+ @mutex.synchronize do
281
+ @error = e
282
+ @closed = true
283
+ @changed.broadcast
284
+ end
285
+ ensure
286
+ @mutex.synchronize do
287
+ @closed = true unless @stopping
288
+ @changed.broadcast
289
+ end
290
+ end
291
+
292
+ def monotonic_now
293
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
294
+ end
295
+ end
296
+
297
+ # A live pub/sub notification. Payloads produced by Database#notify are
298
+ # JSON-decoded; raw non-JSON SQL payloads are returned unchanged.
299
+ Notification = Struct.new(:id, :channel, :payload, :created_at)
300
+
301
+ class Listener
302
+ include Enumerable
303
+
304
+ def initialize(db, channel, fallback_poll_s: 15.0)
305
+ raise ArgumentError, "channel must not be empty" if channel.to_s.empty?
306
+ if !fallback_poll_s.nil? && !fallback_poll_s.to_f.positive?
307
+ raise ArgumentError, "fallback_poll_s must be positive or nil"
308
+ end
309
+
310
+ @db = db
311
+ @channel = channel.to_s
312
+ @fallback_poll_s = fallback_poll_s&.to_f
313
+ @buffer = []
314
+ @state_mutex = Mutex.new
315
+ @state_changed = ConditionVariable.new
316
+ @active_calls = 0
317
+ @active_threads = Hash.new(0)
318
+ @closed = false
319
+ @read_db = SQLite3::Database.new(@db.path)
320
+ @read_db.busy_timeout = 5000
321
+ @read_db.execute("PRAGMA query_only = ON")
322
+ @last_seen = @read_db.get_first_value(
323
+ "SELECT COALESCE(MAX(id), 0) FROM _honker_notifications WHERE channel = ?",
324
+ [@channel],
325
+ ).to_i
326
+ rescue StandardError
327
+ @read_db&.close
328
+ raise
329
+ end
330
+
331
+ attr_reader :channel
332
+
333
+ def next(timeout_s: nil)
334
+ read_db = begin_call
335
+ return nil unless read_db
336
+
337
+ deadline = timeout_s.nil? ? nil : monotonic_now + [timeout_s.to_f, 0.0].max
338
+ loop do
339
+ return nil if closed?
340
+ return @buffer.shift unless @buffer.empty?
341
+
342
+ generation = @db.update_snapshot
343
+ refill(read_db)
344
+ next unless @buffer.empty?
345
+
346
+ remaining = deadline && deadline - monotonic_now
347
+ return nil if remaining && remaining <= 0
348
+
349
+ wait_s = [remaining, @fallback_poll_s].compact.min
350
+ @db.wait_for_update_after(generation, wait_s) { closed? }
351
+ if @db.updates_closed?
352
+ close
353
+ return nil
354
+ end
355
+ end
356
+ rescue StandardError
357
+ close
358
+ raise
359
+ ensure
360
+ end_call if read_db
361
+ end
362
+
363
+ def each
364
+ return enum_for(:each) unless block_given?
365
+
366
+ while (notification = self.next)
367
+ yield notification
368
+ end
369
+ end
370
+
371
+ def close
372
+ should_wake = @state_mutex.synchronize do
373
+ next false if @closed
374
+
375
+ @closed = true
376
+ close_read_db_if_idle
377
+ true
378
+ end
379
+
380
+ if should_wake
381
+ @db.wake_update_waiters
382
+ @db.unregister_listener(self)
383
+ end
384
+
385
+ @state_mutex.synchronize do
386
+ unless @active_threads.key?(Thread.current)
387
+ @state_changed.wait(@state_mutex) until @active_calls.zero?
388
+ close_read_db_if_idle
389
+ end
390
+ end
391
+ nil
392
+ end
393
+
394
+ def closed?
395
+ @state_mutex.synchronize { @closed }
396
+ end
397
+
398
+ private
399
+
400
+ def refill(read_db)
401
+ rows = read_db.execute(
402
+ "SELECT id, channel, payload, created_at " \
403
+ "FROM _honker_notifications " \
404
+ "WHERE channel = ? AND id > ? ORDER BY id LIMIT 1000",
405
+ [@channel, @last_seen],
406
+ )
407
+ rows.each do |id, channel, payload, created_at|
408
+ @last_seen = id.to_i
409
+ @buffer << Notification.new(id.to_i, channel, decode_payload(payload), created_at.to_i)
410
+ end
411
+ end
412
+
413
+ def begin_call
414
+ @state_mutex.synchronize do
415
+ return nil if @closed
416
+
417
+ @active_calls += 1
418
+ @active_threads[Thread.current] += 1
419
+ @read_db
420
+ end
421
+ end
422
+
423
+ def end_call
424
+ @state_mutex.synchronize do
425
+ @active_calls -= 1
426
+ @active_threads[Thread.current] -= 1
427
+ @active_threads.delete(Thread.current) if @active_threads[Thread.current].zero?
428
+ close_read_db_if_idle
429
+ @state_changed.broadcast if @active_calls.zero?
430
+ end
431
+ end
432
+
433
+ def close_read_db_if_idle
434
+ return unless @closed && @active_calls.zero? && @read_db
435
+
436
+ @read_db.close
437
+ @read_db = nil
438
+ end
439
+
440
+ def decode_payload(payload)
441
+ JSON.parse(payload)
442
+ rescue JSON::ParserError, TypeError
443
+ payload
444
+ end
445
+
446
+ def monotonic_now
447
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
448
+ end
449
+ end
450
+
162
451
  DEFAULT_PRAGMAS = <<~SQL
163
452
  PRAGMA journal_mode = WAL;
164
453
  PRAGMA synchronous = NORMAL;
@@ -174,7 +463,7 @@ module Honker
174
463
  # extension loaded. The constructor bootstraps the schema; safe to
175
464
  # open the same path from multiple processes.
176
465
  class Database
177
- attr_reader :db
466
+ attr_reader :db, :path
178
467
 
179
468
  def initialize(path, extension_path: nil, watcher_backend: nil,
180
469
  watcher_poll_interval_ms: nil,
@@ -187,8 +476,8 @@ module Honker
187
476
  end
188
477
 
189
478
  resolved_extension = extension_resolver.resolve(extension_path)
479
+ @path = path
190
480
  @db = SQLite3::Database.new(path)
191
- @local_update_seq = 0
192
481
  @db.busy_timeout = 5000
193
482
  @db.execute("PRAGMA mmap_size = 0")
194
483
  @db.enable_load_extension(true)
@@ -196,24 +485,86 @@ module Honker
196
485
  @db.enable_load_extension(false)
197
486
  @db.execute_batch(DEFAULT_PRAGMAS)
198
487
  @db.execute("SELECT honker_bootstrap()")
199
- @watcher = CoreWatcher.new(path, resolved_extension, watcher_backend, watcher_poll_interval_ms)
488
+ watcher = CoreWatcher.new(path, resolved_extension, watcher_backend, watcher_poll_interval_ms)
489
+ @updates = UpdateHub.new(watcher)
490
+ @listeners_mutex = Mutex.new
491
+ @listeners = {}
492
+ @closed = false
200
493
  end
201
494
 
202
495
  def close
203
- @watcher&.close
496
+ listeners = @listeners_mutex.synchronize do
497
+ return if @closed
498
+
499
+ @closed = true
500
+ registered = @listeners.keys
501
+ @listeners.clear
502
+ registered
503
+ end
504
+ listeners.each(&:close)
505
+ @updates&.close
204
506
  @db&.close
205
507
  end
206
508
 
207
509
  def mark_updated
208
- @local_update_seq += 1
510
+ @updates.signal
209
511
  end
210
512
 
211
513
  def update_snapshot
212
- @local_update_seq
514
+ @updates.snapshot
213
515
  end
214
516
 
215
517
  def wait_for_update(timeout_s)
216
- @watcher.wait(timeout_s)
518
+ @updates.wait(timeout_s)
519
+ end
520
+
521
+ def wait_for_update_after(generation, timeout_s, &cancelled)
522
+ @updates.wait_after(generation, timeout_s, &cancelled)
523
+ end
524
+
525
+ def wake_update_waiters
526
+ @updates.wake
527
+ end
528
+
529
+ def updates_closed?
530
+ @updates.closed?
531
+ end
532
+
533
+ def closed?
534
+ @listeners_mutex.synchronize { @closed }
535
+ end
536
+
537
+ def listen(channel, fallback_poll_s: 15.0)
538
+ raise Error, "database is closed" if closed?
539
+
540
+ listener = Listener.new(self, channel, fallback_poll_s: fallback_poll_s)
541
+ unless register_listener(listener)
542
+ listener.close
543
+ raise Error, "database is closed"
544
+ end
545
+ return listener unless block_given?
546
+
547
+ begin
548
+ listener.each { |notification| yield notification }
549
+ ensure
550
+ listener.close
551
+ end
552
+ end
553
+
554
+ # Internal listener lifecycle hooks. They are public only because the
555
+ # Listener is a separate object rather than a nested implementation detail.
556
+ def register_listener(listener)
557
+ @listeners_mutex.synchronize do
558
+ return false if @closed
559
+
560
+ @listeners[listener] = true
561
+ true
562
+ end
563
+ end
564
+
565
+ def unregister_listener(listener)
566
+ @listeners_mutex.synchronize { @listeners.delete(listener) }
567
+ nil
217
568
  end
218
569
 
219
570
  # Returns a Queue handle for a named queue.
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Russell Romney