wurk 1.8.1 → 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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -13
  3. data/app/controllers/wurk/api_controller.rb +53 -11
  4. data/config/routes.rb +20 -12
  5. data/lib/active_job/queue_adapters/wurk_adapter.rb +8 -2
  6. data/lib/sidekiq/rails.rb +5 -0
  7. data/lib/wurk/api/read_only.rb +1 -1
  8. data/lib/wurk/batch/server_middleware.rb +27 -0
  9. data/lib/wurk/boot_checks.rb +87 -0
  10. data/lib/wurk/capsule.rb +84 -4
  11. data/lib/wurk/cli.rb +77 -10
  12. data/lib/wurk/client/buffered.rb +240 -26
  13. data/lib/wurk/client.rb +16 -11
  14. data/lib/wurk/compat.rb +23 -2
  15. data/lib/wurk/configuration.rb +193 -16
  16. data/lib/wurk/cron.rb +45 -0
  17. data/lib/wurk/debounce.rb +2 -2
  18. data/lib/wurk/encryption.rb +47 -8
  19. data/lib/wurk/error_coalescer.rb +35 -0
  20. data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
  21. data/lib/wurk/fetcher/capped.rb +4 -0
  22. data/lib/wurk/fetcher/many_queues.rb +17 -1
  23. data/lib/wurk/fetcher/oom_drain.rb +102 -0
  24. data/lib/wurk/fetcher/orphan_grace.rb +60 -44
  25. data/lib/wurk/fetcher/private_list_key.rb +17 -15
  26. data/lib/wurk/fetcher/reaper.rb +195 -129
  27. data/lib/wurk/fetcher/reliable.rb +26 -4
  28. data/lib/wurk/fetcher/self_reconcile.rb +175 -0
  29. data/lib/wurk/fetcher/unit_of_work.rb +32 -8
  30. data/lib/wurk/fetcher/unparseable_keys.rb +22 -7
  31. data/lib/wurk/fetcher.rb +6 -1
  32. data/lib/wurk/health.rb +26 -3
  33. data/lib/wurk/health_probe.rb +64 -0
  34. data/lib/wurk/heartbeat.rb +55 -3
  35. data/lib/wurk/job/options.rb +9 -1
  36. data/lib/wurk/job.rb +2 -24
  37. data/lib/wurk/job_logger.rb +1 -1
  38. data/lib/wurk/job_record.rb +17 -9
  39. data/lib/wurk/job_retry.rb +38 -10
  40. data/lib/wurk/job_set.rb +13 -5
  41. data/lib/wurk/job_util.rb +4 -1
  42. data/lib/wurk/launcher.rb +138 -55
  43. data/lib/wurk/leader.rb +9 -3
  44. data/lib/wurk/limiter/base.rb +17 -1
  45. data/lib/wurk/lua/debounce.lua +11 -6
  46. data/lib/wurk/lua/loader.rb +13 -1
  47. data/lib/wurk/lua.rb +72 -22
  48. data/lib/wurk/manager.rb +1 -1
  49. data/lib/wurk/metrics/dashboard_series.rb +21 -2
  50. data/lib/wurk/metrics/history.rb +13 -5
  51. data/lib/wurk/metrics/process_counters.rb +27 -0
  52. data/lib/wurk/metrics/prometheus.rb +35 -14
  53. data/lib/wurk/metrics/query.rb +9 -2
  54. data/lib/wurk/metrics/statsd.rb +8 -0
  55. data/lib/wurk/middleware/chain.rb +85 -13
  56. data/lib/wurk/middleware/current_attributes.rb +26 -10
  57. data/lib/wurk/middleware/poison_pill.rb +1 -2
  58. data/lib/wurk/pool_checkout.rb +1 -1
  59. data/lib/wurk/process_set.rb +3 -1
  60. data/lib/wurk/processor.rb +82 -18
  61. data/lib/wurk/queue_consumers.rb +134 -0
  62. data/lib/wurk/queue_slot.rb +19 -9
  63. data/lib/wurk/rails_boot.rb +44 -5
  64. data/lib/wurk/rails_reloader.rb +51 -0
  65. data/lib/wurk/railtie.rb +27 -4
  66. data/lib/wurk/redact.rb +12 -0
  67. data/lib/wurk/redis_connection.rb +4 -2
  68. data/lib/wurk/redis_pool.rb +34 -9
  69. data/lib/wurk/scheduled.rb +142 -67
  70. data/lib/wurk/sentry/death_handler.rb +29 -0
  71. data/lib/wurk/sentry/error_handler.rb +52 -10
  72. data/lib/wurk/sentry/middleware.rb +8 -1
  73. data/lib/wurk/sentry/retry_policy.rb +12 -3
  74. data/lib/wurk/sentry.rb +42 -5
  75. data/lib/wurk/sorted_entry.rb +38 -13
  76. data/lib/wurk/swarm/child_boot.rb +42 -31
  77. data/lib/wurk/swarm/crash_watch.rb +65 -0
  78. data/lib/wurk/swarm.rb +79 -11
  79. data/lib/wurk/testing.rb +66 -0
  80. data/lib/wurk/unique.rb +11 -8
  81. data/lib/wurk/version.rb +1 -1
  82. data/lib/wurk/web/config.rb +42 -9
  83. data/lib/wurk/web/enterprise.rb +4 -3
  84. data/lib/wurk/web/extension.rb +80 -40
  85. data/lib/wurk/work_set.rb +6 -0
  86. data/lib/wurk/worker/setter.rb +11 -6
  87. data/lib/wurk/worker.rb +17 -1
  88. data/lib/wurk.rb +6 -3
  89. data/vendor/assets/dashboard/assets/{ArgsValue-_MYkd_vA.js → ArgsValue-CmaXArv8.js} +1 -1
  90. data/vendor/assets/dashboard/assets/{BatchDetail-DPmg-hle.js → BatchDetail-nc5yePZW.js} +1 -1
  91. data/vendor/assets/dashboard/assets/{Batches-NTgqSlvD.js → Batches-DgEcyOZ_.js} +1 -1
  92. data/vendor/assets/dashboard/assets/{Busy-2rxcLJMq.js → Busy-CUZncvCy.js} +1 -1
  93. data/vendor/assets/dashboard/assets/{Cron-BFpJgKYb.js → Cron-CIZbaaZs.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{Dashboard-C8VOE2S7.js → Dashboard-BWmfAook.js} +1 -1
  95. data/vendor/assets/dashboard/assets/{Dead-Dse2YEbZ.js → Dead-CGKsTZHq.js} +1 -1
  96. data/vendor/assets/dashboard/assets/Extension-Doy4bJoA.js +1 -0
  97. data/vendor/assets/dashboard/assets/FilterBox-C4bO26_d.js +2 -0
  98. data/vendor/assets/dashboard/assets/{FlowDetail-BWddOtgf.js → FlowDetail-Dh3SJdFh.js} +1 -1
  99. data/vendor/assets/dashboard/assets/{FlowState-6lGsojxE.js → FlowState-yb7virqR.js} +1 -1
  100. data/vendor/assets/dashboard/assets/{Flows-BgKTlYwI.js → Flows-C5SMRuhq.js} +1 -1
  101. data/vendor/assets/dashboard/assets/{JobDetailModal-BrjmO98c.js → JobDetailModal-Cg6sAsJB.js} +1 -1
  102. data/vendor/assets/dashboard/assets/{Limiters-DeUkfbcg.js → Limiters-BIxwDOJM.js} +1 -1
  103. data/vendor/assets/dashboard/assets/Metrics-Qp9q0nF8.js +1 -0
  104. data/vendor/assets/dashboard/assets/{NotFound-CQLLXIfa.js → NotFound-DwjPxFjU.js} +1 -1
  105. data/vendor/assets/dashboard/assets/{PageHeader-CeSaqysY.js → PageHeader-vWeBbSPF.js} +1 -1
  106. data/vendor/assets/dashboard/assets/{Profiles-CI2i2tl8.js → Profiles-7dabYVe3.js} +1 -1
  107. data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
  108. data/vendor/assets/dashboard/assets/{Retries-Dc2RFuTf.js → Retries-CNX8ZtTH.js} +1 -1
  109. data/vendor/assets/dashboard/assets/{Scheduled-B8zOKCLC.js → Scheduled-ClVBj0nc.js} +1 -1
  110. data/vendor/assets/dashboard/assets/{Search-x0eS-V-U.js → Search-BAsVE2qw.js} +1 -1
  111. data/vendor/assets/dashboard/assets/{charts-VggSMFGc.js → charts-DZs138BV.js} +1 -1
  112. data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
  113. data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
  114. data/vendor/assets/dashboard/assets/index-CMzPFxNE.js +141 -0
  115. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BsYWeStI.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
  116. data/vendor/assets/dashboard/assets/{useSort-BWWfpnZH.js → useSort-jBYVwXvg.js} +1 -1
  117. data/vendor/assets/dashboard/assets/{utils-DnzJXljj.js → utils-B0kQFNE8.js} +1 -1
  118. data/vendor/assets/dashboard/index.html +3 -3
  119. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  120. metadata +41 -44
  121. data/vendor/assets/dashboard/assets/Extension-BIc2c1zG.js +0 -1
  122. data/vendor/assets/dashboard/assets/FilterBox-BfZm8ZzH.js +0 -1
  123. data/vendor/assets/dashboard/assets/Metrics-DzmAgBFG.js +0 -1
  124. data/vendor/assets/dashboard/assets/Queues-DUCQ_3St.js +0 -1
  125. data/vendor/assets/dashboard/assets/i18n-B6AbnmJn.js +0 -1
  126. data/vendor/assets/dashboard/assets/index-CTWV8VL4.js +0 -141
  127. data/vendor/assets/dashboard/assets/index-WpZjU076.css +0 -1
data/lib/wurk/cli.rb CHANGED
@@ -11,6 +11,8 @@ require_relative 'configuration'
11
11
  require_relative 'component'
12
12
  require_relative 'capsule'
13
13
  require_relative 'launcher'
14
+ require_relative 'rails_boot'
15
+ require_relative 'boot_checks'
14
16
 
15
17
  module Wurk
16
18
  # Standalone CLI. Loads from `exe/wurk` — never loads `wurk/rails` so the
@@ -29,7 +31,7 @@ module Wurk
29
31
  # Subcommands. Bare `wurk` runs the worker — the historical shape, and the
30
32
  # only one the swarm binaries accept — so a subcommand is never anything
31
33
  # but the first argument, and every argument after it is that command's.
32
- COMMANDS = %w[api].freeze
34
+ COMMANDS = %w[api health].freeze
33
35
 
34
36
  # `wurk api` defaults. 0.0.0.0 because the point of the machine API is for
35
37
  # another service to reach it; it is bearer-gated, and answers 404 to
@@ -99,6 +101,15 @@ module Wurk
99
101
  ['-s', '--server NAME', :api_server, 'Rack handler to serve with (default: the first installed)']
100
102
  ].freeze
101
103
 
104
+ HEALTH_OPTION_FLAGS = [
105
+ ['-w', '--window SECONDS', :health_window, 'A heartbeat older than this does not count (default 60)', :to_i],
106
+ ['-m', '--min COUNT', :health_min, 'Processes on this host that must be beating (default 1)', :to_i],
107
+ ['-u', '--redis-url URL', :health_redis_url, 'Redis to read (default REDIS_URL)'],
108
+ ['-s', '--strict', :health_strict, 'Fail when Redis cannot be read, rather than passing']
109
+ ].freeze
110
+
111
+ HELP_COMMANDS = "\nCommands:\n health Exit 0 if this host's workers are heartbeating\n\nOptions:"
112
+
102
113
  attr_accessor :launcher, :environment, :config
103
114
 
104
115
  # The subcommand `parse` pulled off argv, or nil for the worker runner.
@@ -127,6 +138,8 @@ module Wurk
127
138
  def parse(args = ARGV.dup)
128
139
  @config ||= Wurk.default_configuration
129
140
  @command = extract_command!(args)
141
+ return parse_health_options(args) if @command == 'health'
142
+
130
143
  setup_options(args)
131
144
  initialize_logger
132
145
  validate!
@@ -134,12 +147,16 @@ module Wurk
134
147
  end
135
148
 
136
149
  # `boot_app:` / `warmup:` are test seams. Production always passes true.
137
- def run(boot_app: true, warmup: true)
150
+ def run(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
151
+ exit(run_health) if @command == 'health'
152
+
138
153
  # Mark server mode BEFORE the app loads so `configure_server` blocks in
139
154
  # the required initializer actually fire (they gate on `config.server?`).
140
155
  # Matches Sidekiq, which sets `Sidekiq.server = true` before requiring
141
- # the app in its CLI. Skipping this silently drops server middleware,
142
- # error handlers, and lifecycle hooks registered via configure_server.
156
+ # the app in its CLI. Skipping this drops server middleware, error
157
+ # handlers, and lifecycle hooks registered via configure_server, and each
158
+ # skip logs one WARN naming the gate (`configure_server` / `server? is
159
+ # false`).
143
160
  enter_server_mode
144
161
  boot_application if boot_app
145
162
  self_read, self_write = ::IO.pipe
@@ -147,6 +164,7 @@ module Wurk
147
164
  trap_signals(self_write)
148
165
  validate_redis!
149
166
  validate_pool_sizes!
167
+ BootChecks.run(@config, redis_info)
150
168
  @config[:identity] = identity
151
169
  # Force lazy server-middleware chain so worker threads don't race
152
170
  # against each other constructing it. Spec: Sidekiq::CLI line 104.
@@ -173,23 +191,28 @@ module Wurk
173
191
  # (`WURK_PRELOAD`/`SIDEKIQ_PRELOAD` Bundler groups, `WURK_PRELOAD_APP`/
174
192
  # `SIDEKIQ_PRELOAD_APP` whole-app eager-load) and boots `Process.warmup`
175
193
  # before the fork so children share warmed pages (copy-on-write). Spec §7.
176
- def run_swarm(boot_app: true, warmup: true)
194
+ def run_swarm(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
177
195
  raise ArgumentError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command
178
196
 
179
197
  # Server mode before the app loads — see #run. The flag rides through the
180
198
  # fork into every child (the config object is copied), so configure_server
181
199
  # blocks registered in the parent take effect in the workers.
182
200
  enter_server_mode
201
+ # Only the children read it. Rejected here, a typo fails this boot instead of
202
+ # crash-looping the fleet until the crash-loop exit.
203
+ Health.options_from_env
183
204
  if boot_app
205
+ load_rails_framework
184
206
  preload_bundler_groups
185
207
  boot_application
186
208
  eager_load_application
187
209
  end
188
210
  validate_redis!
189
211
  validate_pool_sizes!
212
+ BootChecks.run(@config, redis_info)
190
213
  @config[:identity] = identity
191
214
  warm_up_process if warmup
192
- @swarm = Wurk::Swarm.new(topology: @config.topology, config: @config,
215
+ @swarm = Wurk::Swarm.new(topology: @config.topology, config: @config, exit_on_crash_loop: true,
193
216
  shutdown_timeout: @config[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
194
217
  begin
195
218
  @swarm.boot(install_signals: true)
@@ -200,6 +223,16 @@ module Wurk
200
223
  # A no-op once the loop already drained (no children, pipe closed).
201
224
  @swarm.shutdown
202
225
  end
226
+ exit(1) if @swarm.crash_looped?
227
+ end
228
+
229
+ # `wurk health` — see Wurk::HealthProbe. Boots nothing; returns the exit code.
230
+ def run_health
231
+ require_relative 'health_probe'
232
+ opts = @health_options
233
+ @config.redis = { url: opts[:health_redis_url] } if opts[:health_redis_url]
234
+ HealthProbe.new(@config, window: opts[:health_window] || HealthProbe::DEFAULT_WINDOW,
235
+ min: opts[:health_min] || 1, strict: opts[:health_strict] == true).call
203
236
  end
204
237
 
205
238
  # `wurk api` — serve the machine-facing HTTP API and nothing else: no
@@ -327,8 +360,8 @@ module Wurk
327
360
  def reopen_logs
328
361
  log = logger
329
362
  log.reopen if log.respond_to?(:reopen)
330
- rescue StandardError
331
- nil
363
+ rescue StandardError => e
364
+ warn "wurk: USR2 log reopen failed, still writing to the previous log: #{e.class}: #{e.message}"
332
365
  end
333
366
 
334
367
  def validate_redis!
@@ -370,6 +403,14 @@ module Wurk
370
403
  COMMANDS.include?(args.first) ? args.shift : nil
371
404
  end
372
405
 
406
+ def parse_health_options(args)
407
+ @health_options = {}
408
+ parser = option_parser(@health_options)
409
+ @parser = parser
410
+ parser.parse!(args)
411
+ self
412
+ end
413
+
373
414
  def setup_options(args)
374
415
  opts = parse_options(args)
375
416
  set_environment(opts[:environment])
@@ -456,8 +497,9 @@ module Wurk
456
497
  require 'optparse'
457
498
 
458
499
  ::OptionParser.new do |o|
459
- o.banner = @command ? "wurk #{@command} [options]" : 'wurk [options]'
460
- define_value_flags(o, opts, OPTION_FLAGS)
500
+ o.banner = @command ? "wurk #{@command} [options]" : "wurk [options]\nwurk COMMAND [options]"
501
+ o.separator HELP_COMMANDS unless @command
502
+ define_value_flags(o, opts, @command == 'health' ? HEALTH_OPTION_FLAGS : OPTION_FLAGS)
461
503
  define_value_flags(o, opts, API_OPTION_FLAGS) if @command == 'api'
462
504
  define_meta_flags(o, opts)
463
505
  end
@@ -490,6 +532,28 @@ module Wurk
490
532
 
491
533
  # --- application bootstrap --------------------------------------------
492
534
 
535
+ # A Rails app expects Rails to be loaded before its gems: `config/application.rb`
536
+ # requires the framework and only then calls `Bundler.require`, and gems read
537
+ # `defined?(Rails)` or reference ActiveModel while loading. Requiring the groups
538
+ # first broke those gems in this boot path only. So for a Rails target, run
539
+ # the app's own first half before the group preload.
540
+ def load_rails_framework
541
+ root = rails_app_root(@config[:require])
542
+ return unless root
543
+
544
+ ENV['RACK_ENV'] = ENV['RAILS_ENV'] = @environment
545
+ require 'rails'
546
+ define_active_job_adapter
547
+ require ::File.join(root, 'config/application.rb')
548
+ end
549
+
550
+ # The app root for a `-r` that is the app directory or its environment file.
551
+ def rails_app_root(require_path)
552
+ path = ::File.expand_path(require_path.to_s)
553
+ path = ::File.dirname(path, 2) if ::File.basename(path) == 'environment.rb'
554
+ path if ::File.directory?(path) && ::File.exist?(::File.join(path, 'config/application.rb'))
555
+ end
556
+
493
557
  # Spec §7.2/§7.3. Before the swarm parent forks, `Bundler.require` the
494
558
  # configured groups so their gems are resident in the parent and paged
495
559
  # copy-on-write into every child. Comma-separated group list; the native
@@ -552,6 +616,9 @@ module Wurk
552
616
  def boot_rails_application(require_path)
553
617
  require 'rails'
554
618
  define_active_job_adapter
619
+ # No railtie here (see boot_application), and a host's after_initialize hook
620
+ # may wrap the reloader, so it has to be the Rails one by then.
621
+ ::ActiveSupport.on_load(:after_initialize, yield: true) { |app| RailsBoot.install_reloader(app) }
555
622
  require ::File.expand_path("#{require_path}/config/environment.rb")
556
623
  @config[:tag] ||= default_tag(::Rails.root) if defined?(::Rails) && ::Rails.respond_to?(:root)
557
624
  end
@@ -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