wurk 0.0.1

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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/CONTRIBUTING.md +73 -0
  4. data/LICENSE +21 -0
  5. data/README.md +137 -0
  6. data/SECURITY.md +39 -0
  7. data/app/controllers/wurk/api/pagination.rb +67 -0
  8. data/app/controllers/wurk/api/serializers.rb +131 -0
  9. data/app/controllers/wurk/api_controller.rb +248 -0
  10. data/app/controllers/wurk/application_controller.rb +7 -0
  11. data/app/controllers/wurk/dashboard_controller.rb +48 -0
  12. data/config/locales/en.yml +15 -0
  13. data/config/routes.rb +34 -0
  14. data/exe/wurk +22 -0
  15. data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
  16. data/lib/generators/wurk/install/install_generator.rb +22 -0
  17. data/lib/generators/wurk/install/templates/wurk.rb +16 -0
  18. data/lib/wurk/active_job/wrapper.rb +32 -0
  19. data/lib/wurk/api/fast.rb +78 -0
  20. data/lib/wurk/batch/buffer.rb +26 -0
  21. data/lib/wurk/batch/callback_job.rb +37 -0
  22. data/lib/wurk/batch/callbacks.rb +176 -0
  23. data/lib/wurk/batch/client_middleware.rb +27 -0
  24. data/lib/wurk/batch/death_handler.rb +39 -0
  25. data/lib/wurk/batch/empty.rb +21 -0
  26. data/lib/wurk/batch/server_middleware.rb +62 -0
  27. data/lib/wurk/batch/status.rb +140 -0
  28. data/lib/wurk/batch.rb +351 -0
  29. data/lib/wurk/batch_set.rb +67 -0
  30. data/lib/wurk/capsule.rb +176 -0
  31. data/lib/wurk/cli.rb +349 -0
  32. data/lib/wurk/client/buffered.rb +372 -0
  33. data/lib/wurk/client.rb +330 -0
  34. data/lib/wurk/compat.rb +136 -0
  35. data/lib/wurk/component.rb +136 -0
  36. data/lib/wurk/configuration.rb +373 -0
  37. data/lib/wurk/context.rb +35 -0
  38. data/lib/wurk/cron.rb +636 -0
  39. data/lib/wurk/dashboard_manifest.rb +39 -0
  40. data/lib/wurk/dead_set.rb +78 -0
  41. data/lib/wurk/deploy.rb +91 -0
  42. data/lib/wurk/embedded.rb +94 -0
  43. data/lib/wurk/encryption.rb +276 -0
  44. data/lib/wurk/engine.rb +81 -0
  45. data/lib/wurk/fetcher/reaper.rb +264 -0
  46. data/lib/wurk/fetcher/reliable.rb +138 -0
  47. data/lib/wurk/fetcher.rb +11 -0
  48. data/lib/wurk/health.rb +193 -0
  49. data/lib/wurk/heartbeat.rb +211 -0
  50. data/lib/wurk/iterable_job.rb +292 -0
  51. data/lib/wurk/job/options.rb +70 -0
  52. data/lib/wurk/job.rb +33 -0
  53. data/lib/wurk/job_logger.rb +68 -0
  54. data/lib/wurk/job_record.rb +156 -0
  55. data/lib/wurk/job_retry.rb +320 -0
  56. data/lib/wurk/job_set.rb +212 -0
  57. data/lib/wurk/job_util.rb +162 -0
  58. data/lib/wurk/keys.rb +52 -0
  59. data/lib/wurk/launcher.rb +289 -0
  60. data/lib/wurk/leader.rb +221 -0
  61. data/lib/wurk/limiter/base.rb +138 -0
  62. data/lib/wurk/limiter/bucket.rb +80 -0
  63. data/lib/wurk/limiter/concurrent.rb +132 -0
  64. data/lib/wurk/limiter/leaky.rb +91 -0
  65. data/lib/wurk/limiter/points.rb +89 -0
  66. data/lib/wurk/limiter/server_middleware.rb +77 -0
  67. data/lib/wurk/limiter/unlimited.rb +48 -0
  68. data/lib/wurk/limiter/window.rb +80 -0
  69. data/lib/wurk/limiter.rb +255 -0
  70. data/lib/wurk/logger.rb +81 -0
  71. data/lib/wurk/lua/loader.rb +53 -0
  72. data/lib/wurk/lua.rb +187 -0
  73. data/lib/wurk/manager.rb +132 -0
  74. data/lib/wurk/metrics/history.rb +151 -0
  75. data/lib/wurk/metrics/query.rb +173 -0
  76. data/lib/wurk/metrics/rollup.rb +169 -0
  77. data/lib/wurk/metrics/statsd.rb +197 -0
  78. data/lib/wurk/metrics.rb +7 -0
  79. data/lib/wurk/middleware/chain.rb +128 -0
  80. data/lib/wurk/middleware/current_attributes.rb +87 -0
  81. data/lib/wurk/middleware/expiry.rb +50 -0
  82. data/lib/wurk/middleware/i18n.rb +63 -0
  83. data/lib/wurk/middleware/interrupt_handler.rb +45 -0
  84. data/lib/wurk/middleware/poison_pill.rb +149 -0
  85. data/lib/wurk/middleware.rb +34 -0
  86. data/lib/wurk/process_set.rb +243 -0
  87. data/lib/wurk/processor.rb +247 -0
  88. data/lib/wurk/queue.rb +108 -0
  89. data/lib/wurk/queues.rb +80 -0
  90. data/lib/wurk/rails.rb +9 -0
  91. data/lib/wurk/railtie.rb +28 -0
  92. data/lib/wurk/redis_pool.rb +79 -0
  93. data/lib/wurk/retry_set.rb +17 -0
  94. data/lib/wurk/scheduled.rb +189 -0
  95. data/lib/wurk/scheduled_set.rb +18 -0
  96. data/lib/wurk/sorted_entry.rb +95 -0
  97. data/lib/wurk/stats.rb +190 -0
  98. data/lib/wurk/swarm/child_boot.rb +105 -0
  99. data/lib/wurk/swarm.rb +260 -0
  100. data/lib/wurk/testing.rb +102 -0
  101. data/lib/wurk/topology.rb +74 -0
  102. data/lib/wurk/unique.rb +240 -0
  103. data/lib/wurk/version.rb +5 -0
  104. data/lib/wurk/web/config.rb +180 -0
  105. data/lib/wurk/web/enterprise.rb +138 -0
  106. data/lib/wurk/web/search.rb +139 -0
  107. data/lib/wurk/web.rb +25 -0
  108. data/lib/wurk/work_set.rb +116 -0
  109. data/lib/wurk/worker/setter.rb +93 -0
  110. data/lib/wurk/worker.rb +216 -0
  111. data/lib/wurk.rb +238 -0
  112. data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
  113. data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
  114. data/vendor/assets/dashboard/index.html +13 -0
  115. data/vendor/assets/dashboard/wurk-manifest.json +4 -0
  116. metadata +232 -0
data/lib/wurk/cli.rb ADDED
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'optparse'
5
+ require 'erb'
6
+
7
+ require_relative 'version'
8
+ require_relative 'configuration'
9
+ require_relative 'component'
10
+ require_relative 'capsule'
11
+ require_relative 'launcher'
12
+
13
+ module Wurk
14
+ # Standalone CLI. Loads from `exe/wurk` — never loads `wurk/rails` so the
15
+ # binary works without the Rails engine (the host app might not be Rails).
16
+ # Singleton because there is exactly one process-wide CLI; tests construct
17
+ # fresh `.new` instances to keep state isolated.
18
+ #
19
+ # Spec: docs/target/sidekiq-free.md §21 (Sidekiq::CLI).
20
+ class CLI # rubocop:disable Metrics/ClassLength
21
+ include Component
22
+
23
+ # Minimum Redis version Wurk supports — same as Sidekiq 8.x. The job JSON
24
+ # format and Lua scripts rely on commands introduced in Redis 7.
25
+ MIN_REDIS_VERSION = '7.0.0'
26
+
27
+ # Thread-backtrace dumper used by both TTIN and INFO. Same body — INFO is
28
+ # the modern name, TTIN is kept for parity with older Sidekiq users.
29
+ BACKTRACE_DUMPER = lambda do |cli|
30
+ Thread.list.each do |thread|
31
+ cli.logger.warn "Thread TID-#{(thread.object_id ^ ::Process.pid).to_s(36)} #{thread.name}"
32
+ if thread.backtrace
33
+ cli.logger.warn thread.backtrace.join("\n")
34
+ else
35
+ cli.logger.warn '<no backtrace available>'
36
+ end
37
+ end
38
+ end
39
+
40
+ SIGNAL_HANDLERS = {
41
+ 'INT' => ->(_cli) { raise Interrupt },
42
+ 'TERM' => ->(_cli) { raise Interrupt },
43
+ 'TSTP' => lambda do |cli|
44
+ cli.logger.info 'Received TSTP, no longer accepting new work'
45
+ cli.launcher.quiet
46
+ end,
47
+ 'TTIN' => BACKTRACE_DUMPER,
48
+ 'INFO' => BACKTRACE_DUMPER
49
+ }.freeze
50
+
51
+ # Table-driven so adding a flag doesn't grow `define_value_flags`'s ABC
52
+ # size and the surface matches the Sidekiq docs row-for-row. The 5th
53
+ # column is the assignment transform: `:to_i` parses as Integer, `:append`
54
+ # pushes onto a list (only `-q` uses that), nil = assign as-is.
55
+ OPTION_FLAGS = [
56
+ ['-c', '--concurrency INT', :concurrency, 'processor threads to use', :to_i],
57
+ ['-e', '--environment ENV', :environment, 'Application environment'],
58
+ ['-g', '--tag TAG', :tag, 'Process tag for procline'],
59
+ ['-q', '--queue QUEUE[,WEIGHT]', :queues, 'Queues to process with optional weights', :append],
60
+ ['-r', '--require [PATH|DIR]', :require, 'Location of Rails app or .rb file to require'],
61
+ ['-t', '--timeout NUM', :timeout, 'Shutdown timeout', :to_i],
62
+ ['-v', '--verbose', :verbose, 'Print more verbose output'],
63
+ ['-C', '--config PATH', :config_file, 'path to YAML config file']
64
+ ].freeze
65
+
66
+ attr_accessor :launcher, :environment, :config
67
+
68
+ def self.instance
69
+ @instance ||= new
70
+ end
71
+
72
+ # Test seam: parallel suites can't share the singleton.
73
+ def self.reset_instance!
74
+ @instance = nil
75
+ end
76
+
77
+ def initialize
78
+ @config = nil
79
+ @launcher = nil
80
+ @environment = nil
81
+ @parser = nil
82
+ end
83
+
84
+ # `parse` is split from `run` so tests can drive option parsing without
85
+ # touching Redis or booting the host app.
86
+ def parse(args = ARGV.dup)
87
+ @config ||= Wurk.default_configuration
88
+ setup_options(args)
89
+ initialize_logger
90
+ validate!
91
+ self
92
+ end
93
+
94
+ # `boot_app:` / `warmup:` are test seams. Production always passes true.
95
+ def run(boot_app: true, warmup: true)
96
+ boot_application if boot_app
97
+ self_read, self_write = ::IO.pipe
98
+ trap_signals(self_write)
99
+ validate_redis!
100
+ validate_pool_sizes!
101
+ @config[:identity] = identity
102
+ # Force lazy server-middleware chain so worker threads don't race
103
+ # against each other constructing it. Spec: Sidekiq::CLI line 104.
104
+ @config.server_middleware
105
+ ::Process.warmup if warmup && ::Process.respond_to?(:warmup) && ENV['RUBY_DISABLE_WARMUP'] != '1'
106
+ fire_event(:startup, reverse: false, reraise: true)
107
+ launch(self_read)
108
+ end
109
+
110
+ def handle_signal(sig)
111
+ logger.debug { "Got #{sig} signal" }
112
+ handler = SIGNAL_HANDLERS[sig]
113
+ return logger.warn("No #{sig} signal handler registered, ignoring") unless handler
114
+
115
+ handler.call(self)
116
+ end
117
+
118
+ private
119
+
120
+ # --- run helpers ----------------------------------------------------
121
+
122
+ def launch(self_read)
123
+ Wurk.server = true
124
+ @launcher = Wurk::Launcher.new(@config)
125
+ begin
126
+ @launcher.run
127
+ while self_read.wait_readable
128
+ signal = self_read.gets&.strip
129
+ break if signal.nil?
130
+
131
+ handle_signal(signal)
132
+ end
133
+ rescue Interrupt
134
+ logger.info 'Shutting down'
135
+ @launcher.stop
136
+ logger.info 'Bye!'
137
+ exit(0)
138
+ end
139
+ end
140
+
141
+ # Self-pipe signal pattern — handlers must be async-signal-safe, so we
142
+ # forward the signal name through a pipe and let the main thread loop
143
+ # over `self_read` in `launch`. Same approach as Sidekiq.
144
+ def trap_signals(self_write)
145
+ signal_names.each do |sig|
146
+ ::Signal.trap(sig) { self_write.puts(sig) }
147
+ rescue ArgumentError
148
+ # JRuby and platforms without certain signals — log and move on.
149
+ warn("Signal #{sig} not supported")
150
+ end
151
+ end
152
+
153
+ def signal_names
154
+ %w[INT TERM TSTP TTIN INFO]
155
+ end
156
+
157
+ def validate_redis!
158
+ info = redis_info
159
+ ver = ::Gem::Version.new(info['redis_version'])
160
+ if ver < ::Gem::Version.new(MIN_REDIS_VERSION)
161
+ raise "You are connected to Redis #{ver}, Wurk requires Redis #{MIN_REDIS_VERSION} or greater"
162
+ end
163
+
164
+ maxmemory_policy = info['maxmemory_policy']
165
+ return if maxmemory_policy.nil? || maxmemory_policy.empty? || maxmemory_policy == 'noeviction'
166
+
167
+ logger.warn { <<~WARN }
168
+
169
+
170
+ WARNING: Your Redis instance will evict Wurk data under heavy load.
171
+ The 'noeviction' maxmemory policy is recommended (current policy: '#{maxmemory_policy}').
172
+
173
+ WARN
174
+ end
175
+
176
+ def redis_info
177
+ @config.redis_pool.info
178
+ end
179
+
180
+ def validate_pool_sizes!
181
+ @config.capsules.each_pair do |name, cap|
182
+ raise ArgumentError, "Pool size too small for #{name}" if cap.redis_pool.size < cap.concurrency
183
+ end
184
+ end
185
+
186
+ # --- options + config-file --------------------------------------------
187
+
188
+ def setup_options(args)
189
+ opts = parse_options(args)
190
+ set_environment(opts[:environment])
191
+ opts[:config_file] ||= discover_config_file(opts[:require] || @config[:require])
192
+ opts = parse_config(opts[:config_file]).merge(opts) if opts[:config_file]
193
+ apply_defaults!(opts)
194
+ apply_options(opts)
195
+ end
196
+
197
+ def apply_defaults!(opts)
198
+ opts[:queues] ||= ['default']
199
+ return if opts[:concurrency] || ENV['RAILS_MAX_THREADS'].nil?
200
+
201
+ opts[:concurrency] = Integer(ENV.fetch('RAILS_MAX_THREADS'))
202
+ end
203
+
204
+ def apply_options(opts)
205
+ @config.merge!(opts.except(:capsules))
206
+ cap = @config.default_capsule
207
+ cap.queues = opts[:queues]
208
+ cap.concurrency = opts[:concurrency] || @config[:concurrency]
209
+ apply_capsules(opts[:capsules])
210
+ end
211
+
212
+ def apply_capsules(capsule_configs)
213
+ capsule_configs&.each do |name, cap_config|
214
+ @config.capsule(name.to_s) do |cap|
215
+ cap.queues = cap_config[:queues] if cap_config[:queues]
216
+ cap.concurrency = cap_config[:concurrency] if cap_config[:concurrency]
217
+ end
218
+ end
219
+ end
220
+
221
+ def discover_config_file(require_path)
222
+ base = ::File.directory?(require_path.to_s) ? require_path.to_s : @config[:require].to_s
223
+ config_dir = ::File.join(base, 'config')
224
+ %w[wurk.yml wurk.yml.erb sidekiq.yml sidekiq.yml.erb].each do |name|
225
+ candidate = ::File.join(config_dir, name)
226
+ return candidate if ::File.exist?(candidate)
227
+ end
228
+ nil
229
+ end
230
+
231
+ def parse_config(path)
232
+ raise ArgumentError, "No such file #{path}" unless ::File.exist?(path)
233
+
234
+ erb = ::ERB.new(::File.read(path), trim_mode: '-')
235
+ erb.filename = ::File.expand_path(path)
236
+ opts = ::YAML.safe_load(erb.result, permitted_classes: [Symbol], aliases: true) || {}
237
+ symbolize_keys_deep!(opts)
238
+ # Environment overlay: `production:` / `staging:` etc.
239
+ overlay = opts.delete(@environment.to_sym) if @environment
240
+ opts.merge!(overlay) if overlay.is_a?(Hash)
241
+ opts.delete(:strict) # Sidekiq parity — strict_fetch removed in 8.x
242
+ opts
243
+ end
244
+
245
+ def symbolize_keys_deep!(hash)
246
+ hash.keys.each do |k|
247
+ sym = k.respond_to?(:to_sym) ? k.to_sym : k
248
+ hash[sym] = hash.delete(k)
249
+ symbolize_keys_deep!(hash[sym]) if hash[sym].is_a?(Hash)
250
+ end
251
+ hash
252
+ end
253
+
254
+ def set_environment(cli_env) # rubocop:disable Naming/AccessorMethodName
255
+ @environment = cli_env || ENV['APP_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
256
+ @config[:environment] = @environment
257
+ end
258
+
259
+ def parse_options(argv)
260
+ opts = {}
261
+ parser = option_parser(opts)
262
+ @parser = parser
263
+ parser.parse!(argv)
264
+ opts
265
+ end
266
+
267
+ def option_parser(opts)
268
+ ::OptionParser.new do |o|
269
+ o.banner = 'wurk [options]'
270
+ define_value_flags(o, opts)
271
+ define_meta_flags(o, opts)
272
+ end
273
+ end
274
+
275
+ def define_value_flags(parser, opts)
276
+ OPTION_FLAGS.each do |short, long, key, desc, transform|
277
+ parser.on(short, long, desc) { |arg| assign_flag(opts, key, arg, transform) }
278
+ end
279
+ end
280
+
281
+ def assign_flag(opts, key, arg, transform)
282
+ case transform
283
+ when :to_i then opts[key] = Integer(arg)
284
+ when :append then (opts[key] ||= []) << arg
285
+ else opts[key] = arg
286
+ end
287
+ end
288
+
289
+ def define_meta_flags(parser, _opts)
290
+ parser.on('-V', '--version', 'Print version and exit') do
291
+ puts "Wurk #{Wurk::VERSION}"
292
+ exit(0)
293
+ end
294
+ parser.on_tail('-h', '--help', 'Show help') do
295
+ puts parser
296
+ exit(0)
297
+ end
298
+ end
299
+
300
+ # --- application bootstrap --------------------------------------------
301
+
302
+ # Standalone mode must NOT load wurk/rails — even when pointed at a Rails
303
+ # app, that's the host's call to wire the railtie. The CLI only `require`s
304
+ # the host's entrypoint and reads the resulting classes.
305
+ def boot_application
306
+ ENV['RACK_ENV'] = ENV['RAILS_ENV'] = @environment
307
+ require_path = @config[:require]
308
+ if ::File.directory?(require_path.to_s)
309
+ boot_rails_application(require_path)
310
+ else
311
+ require ::File.expand_path(require_path)
312
+ @config[:tag] ||= default_tag
313
+ end
314
+ end
315
+
316
+ def boot_rails_application(require_path)
317
+ require 'rails'
318
+ require ::File.expand_path("#{require_path}/config/environment.rb")
319
+ @config[:tag] ||= default_tag(::Rails.root) if defined?(::Rails) && ::Rails.respond_to?(:root)
320
+ end
321
+
322
+ def initialize_logger
323
+ return unless @config[:verbose] || ENV['DEBUG_INVOCATION'] == '1'
324
+
325
+ @config.logger.level = ::Logger::DEBUG
326
+ end
327
+
328
+ def validate!
329
+ require_path = @config[:require]
330
+ bad_path = !::File.exist?(require_path.to_s)
331
+ bad_rails = ::File.directory?(require_path.to_s) &&
332
+ !::File.exist?(::File.join(require_path, 'config/application.rb'))
333
+ return print_help_and_die if bad_path || bad_rails
334
+
335
+ %i[concurrency timeout].each do |opt|
336
+ raise ArgumentError, "#{opt}: #{@config[opt]} is not a valid value" if @config[opt].to_i <= 0
337
+ end
338
+ end
339
+
340
+ def print_help_and_die
341
+ logger.info '=================================================================='
342
+ logger.info ' Please point Wurk to a Rails application or a Ruby file'
343
+ logger.info ' to load your job classes with -r [DIR|FILE].'
344
+ logger.info '=================================================================='
345
+ logger.info @parser
346
+ exit(1)
347
+ end
348
+ end
349
+ end