wurk 1.9.0 → 1.10.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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/wurk/api_controller.rb +4 -1
  3. data/lib/wurk/boot_checks.rb +87 -0
  4. data/lib/wurk/capsule.rb +33 -8
  5. data/lib/wurk/cli.rb +73 -8
  6. data/lib/wurk/client/buffered.rb +240 -26
  7. data/lib/wurk/compat.rb +11 -0
  8. data/lib/wurk/configuration.rb +114 -16
  9. data/lib/wurk/cron.rb +45 -0
  10. data/lib/wurk/debounce.rb +2 -2
  11. data/lib/wurk/error_coalescer.rb +35 -0
  12. data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
  13. data/lib/wurk/fetcher/capped.rb +4 -0
  14. data/lib/wurk/fetcher/many_queues.rb +17 -1
  15. data/lib/wurk/fetcher/oom_drain.rb +102 -0
  16. data/lib/wurk/fetcher/orphan_grace.rb +60 -44
  17. data/lib/wurk/fetcher/private_list_key.rb +1 -16
  18. data/lib/wurk/fetcher/reaper.rb +169 -127
  19. data/lib/wurk/fetcher/reliable.rb +26 -4
  20. data/lib/wurk/fetcher/self_reconcile.rb +175 -0
  21. data/lib/wurk/fetcher/unit_of_work.rb +32 -8
  22. data/lib/wurk/health.rb +23 -4
  23. data/lib/wurk/health_probe.rb +64 -0
  24. data/lib/wurk/heartbeat.rb +55 -3
  25. data/lib/wurk/job/options.rb +9 -1
  26. data/lib/wurk/job.rb +2 -24
  27. data/lib/wurk/job_record.rb +17 -9
  28. data/lib/wurk/job_retry.rb +38 -10
  29. data/lib/wurk/job_set.rb +13 -5
  30. data/lib/wurk/launcher.rb +134 -52
  31. data/lib/wurk/leader.rb +9 -3
  32. data/lib/wurk/limiter/base.rb +17 -1
  33. data/lib/wurk/lua/debounce.lua +11 -6
  34. data/lib/wurk/lua/loader.rb +13 -1
  35. data/lib/wurk/lua.rb +71 -8
  36. data/lib/wurk/manager.rb +1 -1
  37. data/lib/wurk/metrics/process_counters.rb +27 -0
  38. data/lib/wurk/metrics/prometheus.rb +35 -14
  39. data/lib/wurk/middleware/current_attributes.rb +26 -10
  40. data/lib/wurk/middleware/poison_pill.rb +1 -2
  41. data/lib/wurk/process_set.rb +3 -1
  42. data/lib/wurk/processor.rb +82 -18
  43. data/lib/wurk/queue_consumers.rb +134 -0
  44. data/lib/wurk/rails_boot.rb +40 -2
  45. data/lib/wurk/rails_reloader.rb +51 -0
  46. data/lib/wurk/railtie.rb +20 -1
  47. data/lib/wurk/redact.rb +12 -0
  48. data/lib/wurk/redis_pool.rb +34 -9
  49. data/lib/wurk/scheduled.rb +142 -67
  50. data/lib/wurk/sentry/death_handler.rb +29 -0
  51. data/lib/wurk/sentry/error_handler.rb +52 -10
  52. data/lib/wurk/sentry/middleware.rb +8 -1
  53. data/lib/wurk/sentry/retry_policy.rb +12 -3
  54. data/lib/wurk/sentry.rb +42 -5
  55. data/lib/wurk/sorted_entry.rb +38 -13
  56. data/lib/wurk/swarm/child_boot.rb +42 -31
  57. data/lib/wurk/swarm/crash_watch.rb +65 -0
  58. data/lib/wurk/swarm.rb +79 -11
  59. data/lib/wurk/testing.rb +66 -0
  60. data/lib/wurk/version.rb +1 -1
  61. data/lib/wurk/web/extension.rb +6 -1
  62. data/lib/wurk/work_set.rb +6 -0
  63. data/lib/wurk/worker/setter.rb +11 -6
  64. data/lib/wurk/worker.rb +17 -1
  65. data/lib/wurk.rb +1 -1
  66. data/vendor/assets/dashboard/assets/{ArgsValue-DDqX6XWH.js → ArgsValue-CmaXArv8.js} +1 -1
  67. data/vendor/assets/dashboard/assets/{BatchDetail-lIytcn2C.js → BatchDetail-nc5yePZW.js} +1 -1
  68. data/vendor/assets/dashboard/assets/{Batches-BEumt-1O.js → Batches-DgEcyOZ_.js} +1 -1
  69. data/vendor/assets/dashboard/assets/{Busy-Vk9HmZcN.js → Busy-CUZncvCy.js} +1 -1
  70. data/vendor/assets/dashboard/assets/{Cron-CWcM-Cfr.js → Cron-CIZbaaZs.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{Dashboard-Bgv1kusv.js → Dashboard-BWmfAook.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Dead-Di5Rct2H.js → Dead-CGKsTZHq.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Extension-LZY5-imc.js → Extension-Doy4bJoA.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{FilterBox-BDo-7IAy.js → FilterBox-C4bO26_d.js} +1 -1
  75. data/vendor/assets/dashboard/assets/{FlowDetail-CehwRLTt.js → FlowDetail-Dh3SJdFh.js} +1 -1
  76. data/vendor/assets/dashboard/assets/{FlowState-glK2Tnf8.js → FlowState-yb7virqR.js} +1 -1
  77. data/vendor/assets/dashboard/assets/{Flows-cgy-Fsbp.js → Flows-C5SMRuhq.js} +1 -1
  78. data/vendor/assets/dashboard/assets/{JobDetailModal-Cuk3jruU.js → JobDetailModal-Cg6sAsJB.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{Limiters-Ci8G2yZG.js → Limiters-BIxwDOJM.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Metrics-CQFUeZTR.js → Metrics-Qp9q0nF8.js} +1 -1
  81. data/vendor/assets/dashboard/assets/{NotFound-CYjhuDsI.js → NotFound-DwjPxFjU.js} +1 -1
  82. data/vendor/assets/dashboard/assets/{PageHeader-DjIAFTyX.js → PageHeader-vWeBbSPF.js} +1 -1
  83. data/vendor/assets/dashboard/assets/{Profiles-DTowqIRg.js → Profiles-7dabYVe3.js} +1 -1
  84. data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
  85. data/vendor/assets/dashboard/assets/{Retries-SJ3BaKC0.js → Retries-CNX8ZtTH.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Scheduled-DxSRpmFZ.js → Scheduled-ClVBj0nc.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Search-bBbi5JNK.js → Search-BAsVE2qw.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{charts-Bg2YBcaD.js → charts-DZs138BV.js} +1 -1
  89. data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
  90. data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
  91. data/vendor/assets/dashboard/assets/{index-BOTVSP9M.js → index-CMzPFxNE.js} +3 -3
  92. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BRPNyV2M.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
  93. data/vendor/assets/dashboard/assets/{useSort-B-BLJik7.js → useSort-jBYVwXvg.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{utils-CGyrKpTh.js → utils-B0kQFNE8.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +41 -30
  98. data/vendor/assets/dashboard/assets/Queues-DUGRPjME.js +0 -1
  99. data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +0 -1
  100. data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +0 -1
@@ -9,15 +9,28 @@ module Wurk
9
9
  # creation or batch-context pushes (`bid` on payload): BATCH_PUSH has
10
10
  # atomic counter side-effects we can't safely replay.
11
11
  #
12
+ # A failed push opens a circuit for that Redis: later pushes buffer without
13
+ # touching the socket, so one caller pays the connection backoff and the rest
14
+ # do not. The Drainer thread, started on the first failure if none is running,
15
+ # is what probes the server and closes the circuit.
16
+ #
12
17
  # One buffer serves every pool, so each entry remembers the Redis it was
13
18
  # headed for (its {Origin}) and is only ever replayed there: a sharded app
14
19
  # pushing through `Client.via(shard_a)` must not have its outage backlog
15
20
  # land on shard B because B's producer happened to push next.
16
21
  #
17
22
  # Spec: docs/target/sidekiq-pro.md §5.
18
- module Buffered
23
+ module Buffered # rubocop:disable Metrics/ModuleLength
19
24
  DEFAULT_BUFFER_CAP = 1_000
20
25
  DRAINING_KEY = :wurk_reliable_push_draining
26
+ UNBUFFERED_KEY = :wurk_reliable_push_unbuffered
27
+
28
+ # A clean exit tries to deliver what is still in memory, but never holds the
29
+ # process up for longer than this: a hung Redis would otherwise stall shutdown.
30
+ EXIT_FLUSH_SECONDS = 5
31
+ # Paces flush's retries as well as its checks, so a server that keeps refusing
32
+ # gets a few attempts in that window and not hundreds of reports.
33
+ FLUSH_POLL = 0.25
21
34
 
22
35
  # Overflow modes. `:drop_oldest` is the spec default (Sidekiq Pro §5
23
36
  # ring buffer). `:raise` lets callers decide what to do on backpressure
@@ -70,6 +83,67 @@ module Wurk
70
83
  end
71
84
  end
72
85
 
86
+ # Which Redis servers are refusing connections, by pool identity. An entry
87
+ # exists only while its circuit is open, so a rebuilt pool leaves nothing behind.
88
+ class Circuit
89
+ # A circuit closes itself after this long, so a dead drainer costs a caller
90
+ # one attempt per window instead of buffering forever. A failed probe renews it.
91
+ OPEN_SECONDS = 30.0
92
+
93
+ def initialize
94
+ @lock = Mutex.new
95
+ @open_until = {}.compare_by_identity
96
+ end
97
+
98
+ def open?(pool)
99
+ return false if @open_until.empty?
100
+
101
+ @lock.synchronize do
102
+ deadline = @open_until[pool]
103
+ next false unless deadline
104
+ next true if monotonic < deadline
105
+
106
+ @open_until.delete(pool)
107
+ false
108
+ end
109
+ end
110
+
111
+ def trip(pool)
112
+ @lock.synchronize { @open_until[pool] = monotonic + OPEN_SECONDS }
113
+ end
114
+
115
+ def close(pool)
116
+ @lock.synchronize { @open_until.delete(pool) }
117
+ end
118
+
119
+ def any_open?
120
+ !@open_until.empty?
121
+ end
122
+
123
+ # For a circuit with nothing buffered behind it (a batched push tripped it,
124
+ # or `:raise` overflow kept the buffer empty): no replay exists to prove the
125
+ # server is back. Any answer means reachable, and an error such as LOADING
126
+ # belongs to the next real push to report, not to the circuit.
127
+ def ping_all(except:)
128
+ @lock.synchronize { @open_until.keys }.each { |pool| ping(pool) unless except.any? { |o| o.equal?(pool) } }
129
+ end
130
+
131
+ private
132
+
133
+ def ping(pool)
134
+ pool.with { |conn| conn.call('PING') }
135
+ close(pool)
136
+ rescue RedisClient::ConnectionError, ConnectionPool::TimeoutError
137
+ trip(pool)
138
+ rescue RedisClient::CommandError
139
+ close(pool)
140
+ end
141
+
142
+ def monotonic
143
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
144
+ end
145
+ end
146
+
73
147
  # Eagerly initialized: `||=` inside an accessor is not atomic — two
74
148
  # threads racing first-touch could end up holding distinct Mutex
75
149
  # instances and lose all synchronization on the shared buffer.
@@ -84,6 +158,8 @@ module Wurk
84
158
  # buys immunity from both.
85
159
  @install_mutex = Mutex.new
86
160
  @buffer_mutex = Mutex.new
161
+ @replaying = 0
162
+ @circuit = Circuit.new
87
163
 
88
164
  # Process that owns the state above; a mismatch means we're running in a
89
165
  # fork and the inherited copy has to go.
@@ -99,6 +175,7 @@ module Wurk
99
175
 
100
176
  Wurk::Client.prepend(InstanceMethods)
101
177
  @installed = true
178
+ at_exit { Buffered.flush }
102
179
  end
103
180
  end
104
181
 
@@ -145,7 +222,9 @@ module Wurk
145
222
  @buffer = []
146
223
  @buffer_cap = nil
147
224
  @overflow_mode = nil
225
+ @replaying = 0
148
226
  end
227
+ @circuit = Circuit.new
149
228
  @drops = DropLog.new
150
229
  # Stop before dropping: an unstopped drainer thread would otherwise
151
230
  # tick on forever, unreachable, leaking the thread.
@@ -184,8 +263,11 @@ module Wurk
184
263
  @install_mutex = Mutex.new
185
264
  @buffer_mutex = Mutex.new
186
265
  @buffer = []
266
+ @replaying = 0
267
+ @circuit = Circuit.new
187
268
  @drops = DropLog.new
188
- interval = @drainer&.interval
269
+ interval = @drainer&.interval unless @implicit_drainer
270
+ @implicit_drainer = false
189
271
  @drainer = nil
190
272
  start_drainer!(interval: interval) if interval
191
273
  true
@@ -258,32 +340,63 @@ module Wurk
258
340
  # not emit — the replay is where the job actually reaches Redis, so a
259
341
  # buffered-then-drained job counts once as enqueued and once as
260
342
  # recovered.
261
- def drain!(client)
262
- return 0 if buffer_mutex.synchronize { buffer.empty? }
263
-
343
+ #
344
+ # Skipped outright while the circuit is open, unless this is the probe.
345
+ def drain!(client, probe: false)
264
346
  target = client.send(:pool)
265
- drained = 0
266
- while (entry = take_next(target))
267
- break unless replay(client, entry)
347
+ return 0 if !probe && circuit.open?(target)
348
+ return 0 if buffer_mutex.synchronize { buffer.empty? }
268
349
 
269
- drained += 1
270
- end
350
+ drained = replay_all(client, target)
271
351
  drops.end_burst if drained.positive?
272
352
  drained
273
353
  end
274
354
 
355
+ # Pushes inside the block reach Redis or raise. For a caller that goes on to
356
+ # delete its own copy of the job once push returns, which must not count
357
+ # memory as delivered: a crash would then lose the only copy.
358
+ def unbuffered
359
+ previous = Thread.current[UNBUFFERED_KEY]
360
+ Thread.current[UNBUFFERED_KEY] = true
361
+ yield
362
+ ensure
363
+ Thread.current[UNBUFFERED_KEY] = previous
364
+ end
365
+
366
+ def unbuffered?
367
+ Thread.current[UNBUFFERED_KEY] == true
368
+ end
369
+
370
+ # Replays whatever is still buffered, whichever circuit it is behind, for up
371
+ # to `timeout`. Returns how many jobs are left. Runs on a clean exit; a
372
+ # Launcher or host shutting down earlier can call it too. Forked children
373
+ # inherit the at_exit hook, but start with an empty buffer of their own.
374
+ def flush(timeout: EXIT_FLUSH_SECONDS)
375
+ return 0 if undelivered_count.zero?
376
+
377
+ retry_until_delivered(flush_clock + timeout)
378
+ undelivered_count.tap { |left| report_undelivered(left) if left.positive? }
379
+ end
380
+
381
+ def trip!(pool)
382
+ circuit.trip(pool)
383
+ ensure_drainer
384
+ end
385
+
275
386
  # The background drainer's pass: every origin in the buffer, each
276
387
  # through a client of its own, so one Redis still down does not hold
277
388
  # back another that has recovered. Returns the total replayed; the
278
389
  # first non-transient error is re-raised after every origin had its turn.
279
390
  def drain_all!
280
391
  failure = nil
281
- drained = buffered_pools.sum do |pool|
282
- drain!(Wurk::Client.new(pool: pool))
392
+ pools = buffered_pools
393
+ drained = pools.sum do |pool|
394
+ drain!(Wurk::Client.new(pool: pool), probe: true)
283
395
  rescue StandardError => e
284
396
  failure ||= e
285
397
  0
286
398
  end
399
+ circuit.ping_all(except: pools)
287
400
  raise failure if failure
288
401
 
289
402
  drained
@@ -294,7 +407,7 @@ module Wurk
294
407
  @buffer ||= []
295
408
  end
296
409
 
297
- attr_reader :drops
410
+ attr_reader :drops, :circuit
298
411
 
299
412
  # Start a background drain thread that wakes every `interval`
300
413
  # seconds and tries to flush the buffer. Idempotent — replaces
@@ -305,6 +418,7 @@ module Wurk
305
418
  def start_drainer!(interval: Drainer::DEFAULT_INTERVAL)
306
419
  install_mutex.synchronize do
307
420
  @drainer&.stop
421
+ @implicit_drainer = false
308
422
  @drainer = Drainer.new(interval: interval)
309
423
  @drainer.start
310
424
  end
@@ -325,14 +439,83 @@ module Wurk
325
439
 
326
440
  attr_reader :install_mutex, :buffer_mutex
327
441
 
442
+ # A replay holds its entry outside the buffer until it lands or goes back, so
443
+ # an empty buffer alone does not mean every job reached Redis.
444
+ def undelivered_count
445
+ buffer_mutex.synchronize { buffer.size + @replaying }
446
+ end
447
+
448
+ def retry_until_delivered(deadline)
449
+ worker = nil
450
+ until undelivered_count.zero? || flush_clock >= deadline
451
+ worker = start_flush_attempt unless worker&.alive? || buffer_size.zero?
452
+ sleep(FLUSH_POLL)
453
+ end
454
+ end
455
+
456
+ def flush_clock
457
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
458
+ end
459
+
460
+ def start_flush_attempt
461
+ Thread.new do
462
+ drain_all!
463
+ rescue StandardError => e
464
+ Wurk.configuration.handle_exception(e, { context: 'reliable_push flush' })
465
+ end
466
+ end
467
+
468
+ def report_undelivered(left)
469
+ Wurk.configuration.logger.error do
470
+ "reliable_push: #{left} buffered job(s) could not reach Redis before exit and are lost"
471
+ end
472
+ rescue StandardError
473
+ nil
474
+ end
475
+
476
+ # Leaves the circuit open on the first failed replay and closed once the
477
+ # backlog for this Redis has run out.
478
+ def replay_all(client, target)
479
+ drained = 0
480
+ while (entry = take_next(target))
481
+ unless replay(client, entry)
482
+ trip!(target)
483
+ return drained
484
+ end
485
+ drained += 1
486
+ end
487
+ circuit.close(target)
488
+ drained
489
+ end
490
+
491
+ # An app's own drainer keeps its interval; only the absence of one makes
492
+ # this implicit, which is what stops `reset_after_fork!` re-arming it in
493
+ # children that never pushed.
494
+ def ensure_drainer
495
+ install_mutex.synchronize do
496
+ next if @drainer&.running?
497
+
498
+ @implicit_drainer = @drainer.nil?
499
+ @drainer ||= Drainer.new
500
+ @drainer.start
501
+ end
502
+ end
503
+
328
504
  # The oldest entry bound for `target`, removed from the buffer.
329
505
  def take_next(target)
330
506
  buffer_mutex.synchronize do
331
507
  index = buffer.index { |entry| entry.origin.resolve.equal?(target) }
332
- index && buffer.delete_at(index)
508
+ next unless index
509
+
510
+ @replaying += 1
511
+ buffer.delete_at(index)
333
512
  end
334
513
  end
335
514
 
515
+ def replay_settled
516
+ buffer_mutex.synchronize { @replaying = [@replaying - 1, 0].max }
517
+ end
518
+
336
519
  def buffered_pools
337
520
  buffer_mutex.synchronize { buffer.map { |entry| entry.origin.resolve } }.uniq(&:object_id)
338
521
  end
@@ -348,8 +531,10 @@ module Wurk
348
531
  # Non-connection failures (OOM, LOADING, READONLY…) must not drop
349
532
  # the taken payload — restore it before propagating, or a
350
533
  # recovering-but-not-ready Redis silently eats one buffered job per
351
- # drain tick.
534
+ # drain tick. Redis answered, so the circuit closes and pushes go back
535
+ # to surfacing the refusal to their callers.
352
536
  restore(entry)
537
+ circuit.close(client.send(:pool))
353
538
  raise
354
539
  end
355
540
  unless replayed
@@ -360,6 +545,8 @@ module Wurk
360
545
  client.send(:emit_enqueued, [entry.payload])
361
546
  Wurk::Metrics::Statsd.increment('jobs.recovered.push')
362
547
  entry
548
+ ensure
549
+ replay_settled
363
550
  end
364
551
 
365
552
  def restore(entry)
@@ -450,8 +637,10 @@ module Wurk
450
637
  # racing first pushes different logs.
451
638
  @drops = DropLog.new
452
639
 
453
- # Background drain thread. Wakes every `interval` seconds and runs
454
- # `Buffered.drain_all!`. drain! already short-circuits on the first
640
+ # Background drain thread and the circuit's only prober. Started on the first
641
+ # failed push if the app did not start one; wakes every `interval` seconds and
642
+ # runs `Buffered.drain_all!`, which costs no Redis round trip while the buffer
643
+ # is empty and no circuit is open. drain! already short-circuits on the first
455
644
  # transient failure, so a still-down Redis just leaves its entries alone
456
645
  # for this tick — no exponential backoff or explicit "reconnect
457
646
  # detection" needed; the inner connection retry already lives inside
@@ -459,6 +648,9 @@ module Wurk
459
648
  class Drainer
460
649
  DEFAULT_INTERVAL = 2.0
461
650
  STOP_JOIN_TIMEOUT = 5.0
651
+ # While a circuit is open, pushes are only in memory, so finding out that
652
+ # Redis is back quickly matters more than the interval an idle drainer keeps.
653
+ PROBE_PAUSE = 0.25
462
654
 
463
655
  # Read by `Buffered.reset_after_fork!` off the *inherited* drainer, to
464
656
  # rebuild an equivalent one in the child without touching its lock.
@@ -530,7 +722,8 @@ module Wurk
530
722
  # Mutex+ConditionVariable lets `stop` wake the thread immediately
531
723
  # instead of waiting up to `interval` seconds for sleep to return.
532
724
  def wait_interval
533
- @lock.synchronize { @wake.wait(@lock, @interval) unless @done }
725
+ pause = Buffered.circuit.any_open? ? [@interval, PROBE_PAUSE].min : @interval
726
+ @lock.synchronize { @wake.wait(@lock, pause) unless @done }
534
727
  end
535
728
  end
536
729
 
@@ -540,12 +733,12 @@ module Wurk
540
733
  # (ConnectionPool::TimeoutError) — and buffers non-batched payloads.
541
734
  module InstanceMethods
542
735
  def push(item)
543
- Buffered.drain!(self)
736
+ Buffered.drain!(self) unless Buffered.unbuffered?
544
737
  super
545
738
  end
546
739
 
547
740
  def push_bulk(items)
548
- Buffered.drain!(self)
741
+ Buffered.drain!(self) unless Buffered.unbuffered?
549
742
  super
550
743
  end
551
744
 
@@ -555,21 +748,42 @@ module Wurk
555
748
  # than in Client because this rescue is its only reader: a Client
556
749
  # without reliable_push! never allocates it.
557
750
  def raw_push(payloads)
751
+ return buffer_without_trying(payloads) if circuit_open_for?(payloads)
752
+
558
753
  Thread.current[DELIVERED_KEY] = []
559
754
  super
560
755
  rescue RedisClient::ConnectionError, ConnectionPool::TimeoutError
561
- raise if Thread.current[Buffered::DRAINING_KEY]
756
+ raise if Thread.current[Buffered::DRAINING_KEY] || Buffered.unbuffered?
562
757
 
758
+ buffer_undelivered(payloads)
759
+ ensure
760
+ Thread.current[DELIVERED_KEY] = nil
761
+ end
762
+
763
+ # Runs inside raw_push's rescue, so a bare `raise` is its connection error.
764
+ # Client#push subtracts the returned payloads from the enqueued metric: they
765
+ # are in the buffer, not in Redis. Whatever the group did deliver stays out
766
+ # of the set and still counts.
767
+ def buffer_undelivered(payloads)
768
+ Buffered.trip!(pool)
563
769
  bidless, batched = undelivered(payloads).partition { |p| !p['bid'] }
564
770
  enbuffer_bidless(bidless, batched)
565
771
  raise unless batched.empty?
566
772
 
567
- # Client#push subtracts these from the enqueued metric: they are in
568
- # the buffer, not in Redis. Whatever the group did deliver stays out
569
- # of the set and still counts.
570
773
  bidless
571
- ensure
572
- Thread.current[DELIVERED_KEY] = nil
774
+ end
775
+
776
+ # Batched payloads never buffer, so they still take the real attempt and
777
+ # report its outcome to the caller.
778
+ def circuit_open_for?(payloads)
779
+ return false if Thread.current[Buffered::DRAINING_KEY] || Buffered.unbuffered?
780
+
781
+ Buffered.circuit.open?(pool) && payloads.none? { |p| p['bid'] }
782
+ end
783
+
784
+ def buffer_without_trying(payloads)
785
+ Buffered.enbuffer(payloads, client: self)
786
+ payloads
573
787
  end
574
788
 
575
789
  # The payloads this push is not known to have written. A push spanning
data/lib/wurk/compat.rb CHANGED
@@ -113,6 +113,11 @@ module Sidekiq
113
113
  end
114
114
  end
115
115
 
116
+ module RailsNamespace
117
+ Reloader = Wurk::RailsReloader
118
+ end
119
+ private_constant :RailsNamespace
120
+
116
121
  BasicFetch = Wurk::Fetcher::Reliable
117
122
  Batch = Wurk::Batch
118
123
  BatchSet = Wurk::BatchSet
@@ -224,6 +229,12 @@ module Sidekiq
224
229
  Wurk.default_job_options = hash
225
230
  end
226
231
 
232
+ # Answered from const_missing rather than defined as a constant, which would
233
+ # shadow ::Rails for every bare `Rails.logger` written inside `module Sidekiq`.
234
+ def const_missing(name)
235
+ name == :Rails ? RailsNamespace : super
236
+ end
237
+
227
238
  def strict_args!(mode = :raise) = Wurk.strict_args!(mode)
228
239
  def transactional_push! = Wurk.transactional_push!
229
240
  def testing!(mode = :fake, &) = Wurk.testing!(mode, &)