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 +4 -4
- data/README.md +13 -0
- data/ext/honker/honker-core/src/lib.rs +7 -2
- data/lib/honker/version.rb +1 -1
- data/lib/honker.rb +358 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4186dca28701dc5b7f2654da008e49ba84c51218a3d7141d6ff5c5d842ef3875
|
|
4
|
+
data.tar.gz: 12e5f86f35bc2832405fa98a7c60ca0667b9577ab2041ce571893b7596f46613
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
3446
|
-
"kernel watcher shutdown took {elapsed:?}, expected <
|
|
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
|
);
|
data/lib/honker/version.rb
CHANGED
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
510
|
+
@updates.signal
|
|
209
511
|
end
|
|
210
512
|
|
|
211
513
|
def update_snapshot
|
|
212
|
-
@
|
|
514
|
+
@updates.snapshot
|
|
213
515
|
end
|
|
214
516
|
|
|
215
517
|
def wait_for_update(timeout_s)
|
|
216
|
-
@
|
|
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.
|