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
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ class Web
5
+ # Web UI configuration. Holds the authorization callback documented in
6
+ # docs/target/sidekiq-ent.md §9.2 — a Rack-level hook called with
7
+ # `(env, method, path)` per request. Truthy return proceeds; falsey
8
+ # short-circuits to 403.
9
+ #
10
+ # Wurk ships the Ent feature in the free gem. No license check; the
11
+ # block runs unconditionally when present.
12
+ #
13
+ # Example:
14
+ #
15
+ # Wurk::Web.configure do |c|
16
+ # c.authorization do |env, method, _path|
17
+ # user = env['warden']&.user
18
+ # method == 'GET' ? user&.support? || user&.admin? : user&.admin?
19
+ # end
20
+ # end
21
+ #
22
+ # When no block is registered, every request is authorized (matches
23
+ # Sidekiq's default — no auth until the user opts in).
24
+ class Config
25
+ # String forms that mean "off" — so `config.web.read_only = ENV[...]`
26
+ # doesn't flip on when the env var is "0"/"false"/empty.
27
+ FALSEY_STRINGS = ['', '0', 'false', 'no', 'off'].freeze
28
+
29
+ # Host-app Rack middleware stacked in front of the dashboard, newest
30
+ # last. Each entry is `[middleware, args, block]`. Returns a frozen copy
31
+ # so the memoized chain (`#rack_app`) can only be invalidated through
32
+ # `#use` — direct mutation can't silently desync it.
33
+ def middlewares
34
+ @middlewares.dup.freeze
35
+ end
36
+
37
+ def initialize
38
+ @authorization = nil
39
+ @read_only = env_read_only?
40
+ @middlewares = []
41
+ @rack_app = nil
42
+ end
43
+
44
+ # Registers a `(env, method, path) -> truthy/falsey` block. Re-calling
45
+ # overwrites; the spec exposes a single hook, not a chain.
46
+ def authorization(&block)
47
+ @authorization = block if block
48
+ @authorization
49
+ end
50
+
51
+ # Sidekiq-compatible (`Sidekiq::Web.use`). Registers a Rack middleware
52
+ # that wraps the dashboard, in front of the authorization hook, so a
53
+ # host app can gate the UI with Devise/Warden/Sorcery/Rack::Auth::Basic
54
+ # without writing its own middleware. `args` and an optional block pass
55
+ # straight through to the middleware's `new`. Call before the first
56
+ # request (i.e. from an initializer) — the chain is built once.
57
+ def use(middleware, *args, &block)
58
+ @middlewares << [middleware, args, block]
59
+ @rack_app = nil
60
+ end
61
+
62
+ # Builds (once) the host-middleware chain wrapping `inner` and memoizes
63
+ # it on this Config. `reset_config!` swaps in a fresh Config, so each
64
+ # test rebuilds cleanly; production builds exactly once at boot.
65
+ def rack_app(inner)
66
+ @rack_app ||= begin
67
+ stack = @middlewares
68
+ ::Rack::Builder.new do
69
+ stack.each { |middleware, args, block| use(middleware, *args, &block) }
70
+ run inner
71
+ end.to_app
72
+ end
73
+ end
74
+
75
+ # Read-only mode. When on, the Authorization middleware blocks every
76
+ # non-GET request (retry/kill/requeue/delete/pause/resume/clear) with
77
+ # 403, and the SPA hides destructive actions via the /api/meta flag.
78
+ # Defaults from WURK_WEB_READ_ONLY=1 so a viewer-only deploy (e.g. the
79
+ # public demo) needs no Ruby config.
80
+ def read_only=(value)
81
+ @read_only = value.is_a?(String) ? !FALSEY_STRINGS.include?(value.strip.downcase) : !!value
82
+ end
83
+
84
+ def read_only?
85
+ @read_only
86
+ end
87
+
88
+ def reset!
89
+ @authorization = nil
90
+ @read_only = env_read_only?
91
+ @middlewares = []
92
+ @rack_app = nil
93
+ end
94
+
95
+ # Returns true when no block is registered, otherwise the block's
96
+ # truthiness. The `path` argument is the engine-relative path so a
97
+ # consumer mounting under `/wurk` sees `/api/stats`, not the host's
98
+ # absolute path — that matches Sidekiq's contract.
99
+ def authorized?(env, method, path)
100
+ return true if @authorization.nil?
101
+
102
+ !!@authorization.call(env, method, path)
103
+ end
104
+
105
+ private
106
+
107
+ def env_read_only?
108
+ ENV['WURK_WEB_READ_ONLY'] == '1'
109
+ end
110
+ end
111
+
112
+ class << self
113
+ def config
114
+ @config ||= Config.new
115
+ end
116
+
117
+ def configure
118
+ yield config
119
+ end
120
+
121
+ # Class-level shorthand for `config.use` — mirrors `Sidekiq::Web.use`.
122
+ def use(...)
123
+ config.use(...)
124
+ end
125
+
126
+ # Test helper — exposed for parity with `Wurk::Limiter.reset_config!`.
127
+ # Production callers should not need to drop the auth block at runtime.
128
+ def reset_config!
129
+ @config = nil
130
+ end
131
+ end
132
+
133
+ # Rack middleware inserted into the engine. Resolves PATH_INFO + REQUEST_METHOD
134
+ # from `env` and delegates to `Wurk::Web.config`. The engine's mount path
135
+ # is stripped via `SCRIPT_NAME` so the callback sees engine-relative paths.
136
+ class Authorization
137
+ FORBIDDEN_BODY = 'Forbidden'
138
+ READ_ONLY_BODY = 'Read-only mode'
139
+ FORBIDDEN_HEADERS = { 'Content-Type' => 'text/plain' }.freeze
140
+ # Methods allowed while read-only. Anything else is a mutation and 403s.
141
+ SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
142
+
143
+ def initialize(app)
144
+ @app = app
145
+ end
146
+
147
+ def call(env)
148
+ method = env['REQUEST_METHOD']
149
+ path = env['PATH_INFO'].to_s
150
+ config = Wurk::Web.config
151
+ return forbidden(FORBIDDEN_BODY) unless config.authorized?(env, method, path)
152
+ return forbidden(READ_ONLY_BODY) if config.read_only? && !SAFE_METHODS.include?(method)
153
+
154
+ @app.call(env)
155
+ end
156
+
157
+ private
158
+
159
+ def forbidden(body)
160
+ [403, FORBIDDEN_HEADERS.dup, [body]]
161
+ end
162
+ end
163
+
164
+ # Engine Rack middleware that applies the host-registered `Wurk::Web.use`
165
+ # chain (Devise/Warden/Sorcery/Rack::Auth::Basic) in front of the
166
+ # dashboard. Inserted ahead of `Authorization` so host auth runs first and
167
+ # its `env` (e.g. `env['warden']`) is visible to the authorization hook.
168
+ # The chain is built lazily on first request — after host initializers
169
+ # have run — then memoized on the Config.
170
+ class MiddlewareStack
171
+ def initialize(app)
172
+ @app = app
173
+ end
174
+
175
+ def call(env)
176
+ Wurk::Web.config.rack_app(@app).call(env)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../limiter'
4
+ require_relative '../cron'
5
+ require_relative '../client'
6
+ require_relative '../metrics/query'
7
+
8
+ module Wurk
9
+ class Web
10
+ # Web UI Ent-feature surface (spec: docs/target/sidekiq-ent.md §9.1).
11
+ # Each tab — Limits, Periodic, Historical — gets a small helper module
12
+ # that the API controller delegates to. The data fabric lives here so
13
+ # the controller stays a thin JSON shim and so library users wanting
14
+ # the same operations programmatically (cron management scripts,
15
+ # operator REPL, etc.) have a single entry point.
16
+ #
17
+ # Wurk ships these free; no license gate, no env flag.
18
+ module Enterprise
19
+ # Limits tab — list every registered limiter, filter by name, expose
20
+ # reset + delete. List/metrics already render via `Wurk::Limiter::Base`;
21
+ # this wrapper adds the name filter documented in §1.2.0+.
22
+ module Limits
23
+ module_function
24
+
25
+ # @return [Array<String>] limiter names, optionally filtered to those
26
+ # whose name contains the case-insensitive substring `filter`.
27
+ def list(filter: nil)
28
+ names = Wurk.redis { |c| c.call('SMEMBERS', Wurk::Limiter::LIST_KEY) }.sort
29
+ return names if filter.nil? || filter.to_s.empty?
30
+
31
+ needle = filter.to_s.downcase
32
+ names.select { |n| n.downcase.include?(needle) }
33
+ end
34
+
35
+ def metadata(name)
36
+ raw = Wurk.redis { |c| c.call('HGETALL', "lmtr:#{name}") }
37
+ raw.is_a?(Array) ? raw.each_slice(2).to_h : raw
38
+ end
39
+
40
+ # Stats-key reset: drops every `lmtr-stats:` / state key for the
41
+ # named limiter while keeping its metadata + LIST membership so the
42
+ # row remains in the UI. Mirrors the §1.5 `#reset` surface — the
43
+ # name is wire-compat, so the trailing-? rule doesn't apply here.
44
+ def reset(name) # rubocop:disable Naming/PredicateMethod
45
+ Wurk.redis do |c|
46
+ %W[lmtr-cs:#{name} lmtr-b:#{name} lmtr-w:#{name} lmtr-l:#{name}
47
+ lmtr-p:#{name} lmtr-stats:#{name}].each { |k| c.call('DEL', k) }
48
+ end
49
+ true
50
+ end
51
+ end
52
+
53
+ # Periodic tab — list/pause/unpause/enqueue-now/history. The list view
54
+ # reuses `Wurk::Cron::LoopSet`; mutating actions write directly to the
55
+ # `loops:{lid}` HASH so they're idempotent and crash-safe.
56
+ module Periodic
57
+ module_function
58
+
59
+ def list
60
+ Wurk::Cron::LoopSet.new
61
+ end
62
+
63
+ def fetch(lid)
64
+ Wurk::Cron::LoopSet.new.fetch(lid)
65
+ end
66
+
67
+ def pause(lid)
68
+ set_paused(lid, '1')
69
+ end
70
+
71
+ def unpause(lid)
72
+ set_paused(lid, '0')
73
+ end
74
+
75
+ # Spec §2.4 "enqueue-now": pushes a one-off run with the loop's
76
+ # configured klass/queue/args/retry. Returns the new jid, or nil
77
+ # when the loop doesn't exist.
78
+ def enqueue_now(lid)
79
+ loop_obj = fetch(lid)
80
+ return nil unless loop_obj
81
+
82
+ Wurk::Client.new.push(
83
+ 'class' => loop_obj.klass,
84
+ 'args' => loop_obj.args,
85
+ 'queue' => loop_obj.queue,
86
+ 'retry' => loop_obj.retry_value
87
+ )
88
+ end
89
+
90
+ # Spec §8.0.1+: per-loop history list at `loop-history:{lid}`. Each
91
+ # entry is a `[fired_at, jid]` tuple (LPUSH'd by the poller).
92
+ def history(lid)
93
+ loop_obj = fetch(lid)
94
+ return [] unless loop_obj
95
+
96
+ loop_obj.history
97
+ end
98
+
99
+ def set_paused(lid, value) # rubocop:disable Naming/PredicateMethod
100
+ loop_obj = fetch(lid)
101
+ return false unless loop_obj
102
+
103
+ Wurk.redis { |c| c.call('HSET', "#{Wurk::Cron::LOOP_PREFIX}#{lid}", 'paused', value) }
104
+ true
105
+ end
106
+ end
107
+
108
+ # Historical tab — per-class and per-queue gauges over a recent window.
109
+ # The minute / hour HASH layout comes from `Wurk::Metrics::History`;
110
+ # `Query.for_job` / `Query.top_jobs` already does the fan-out. The
111
+ # wrapper here is a stable entry point so the controller doesn't reach
112
+ # into the `Query` module directly (and so we have one place to layer
113
+ # additional aggregations on later — global gauges across all classes).
114
+ module Historical
115
+ module_function
116
+
117
+ # Per-class time series. Pass exactly one of `minutes:` / `hours:`.
118
+ # Returns `[{at: Time, p:, f:, ms:}, …]` ordered oldest→newest.
119
+ def for_job(klass, minutes: nil, hours: nil, now: ::Time.now)
120
+ Wurk::Metrics::Query.for_job(klass, minutes: minutes, hours: hours, now: now)
121
+ end
122
+
123
+ # Global top-N rollup. Wraps `Query.top_jobs` and keeps the wire
124
+ # shape of `[[class_name, {p:, f:, ms:}], ...]`.
125
+ def top(minutes: 60, class_filter: nil)
126
+ Wurk::Metrics::Query.top_jobs(minutes: minutes, class_filter: class_filter)
127
+ end
128
+
129
+ # Cluster-total time-series for the dashboard charts. `bucket` is
130
+ # '1m'/'5m'/'1h'; `window` is in seconds (clamped to the bucket's
131
+ # retention). Returns `[{at:, p:, f:, ms:}, …]` oldest→newest.
132
+ def history(bucket, window:, now: ::Time.now)
133
+ Wurk::Metrics::Query.history(bucket, window, now: now)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../queue'
4
+ require_relative '../retry_set'
5
+ require_relative '../scheduled_set'
6
+ require_relative '../dead_set'
7
+
8
+ module Wurk
9
+ class Web
10
+ # Pro feature parity: substring search across queues / retries /
11
+ # scheduled / dead. Spec: docs/target/sidekiq-pro.md §10.1 ("Search box
12
+ # on Retry/Scheduled/Dead pages, substring across job payload via ZSCAN").
13
+ # Wurk ships it free, extended to also cover the queue LIST.
14
+ #
15
+ # ZSET stores use `ZSCAN MATCH *needle*` (the substring is literal,
16
+ # wrapped in glob stars — Redis matches the raw JSON payload). The queue
17
+ # LIST falls back to a paged LRANGE filter since LIST has no SCAN.
18
+ #
19
+ # Result shape mirrors `Wurk::Api::Serializers#sorted_entry` so the SPA
20
+ # renders search hits with the same component as a sorted-set row.
21
+ class Search
22
+ DEFAULT_LIMIT = 100
23
+ MAX_LIMIT = 500
24
+ KINDS = %w[queues retry scheduled dead].freeze
25
+ QUEUE_PAGE = 50
26
+ ZSCAN_PAGE = 200
27
+
28
+ attr_reader :substring, :kinds, :limit
29
+
30
+ def initialize(substring, kinds: KINDS, limit: DEFAULT_LIMIT)
31
+ @substring = substring.to_s
32
+ @kinds = (Array(kinds).map(&:to_s) & KINDS)
33
+ @kinds = KINDS.dup if @kinds.empty?
34
+ @limit = limit.to_i.clamp(1, MAX_LIMIT)
35
+ end
36
+
37
+ # Streams matching hits across every selected store. Stops at `limit`.
38
+ # Yields Hashes shaped like sorted_entry payloads with an extra
39
+ # `:kind` discriminator + `:name` (queue name or set name).
40
+ def each(&)
41
+ return enum_for(:each) unless block_given?
42
+ return if @substring.empty?
43
+
44
+ emitted = 0
45
+ each_hit do |row|
46
+ yield row
47
+ emitted += 1
48
+ break if emitted >= @limit
49
+ end
50
+ end
51
+
52
+ def to_a
53
+ each.to_a
54
+ end
55
+
56
+ private
57
+
58
+ def each_hit(&block)
59
+ @kinds.each do |kind|
60
+ case kind
61
+ when 'queues' then search_queues(&block)
62
+ else search_sorted_set(kind, &block)
63
+ end
64
+ end
65
+ end
66
+
67
+ def search_queues
68
+ Queue.all.each do |queue|
69
+ paged_lrange(queue.name) do |payload|
70
+ next unless payload.include?(@substring)
71
+
72
+ record = JobRecord.new(payload, queue.name)
73
+ yield queue_row(queue.name, record)
74
+ end
75
+ end
76
+ end
77
+
78
+ def paged_lrange(queue_name, &block)
79
+ key = Keys.queue(queue_name)
80
+ page = 0
81
+ loop do
82
+ start = page * QUEUE_PAGE
83
+ stop = start + QUEUE_PAGE - 1
84
+ slice = Wurk.redis { |c| c.call('LRANGE', key, start, stop) }
85
+ slice.each(&block)
86
+ break if slice.size < QUEUE_PAGE
87
+
88
+ page += 1
89
+ end
90
+ end
91
+
92
+ def search_sorted_set(kind, &)
93
+ set = sorted_set_for(kind)
94
+ set.scan(@substring, ZSCAN_PAGE) do |value, score|
95
+ yield sorted_row(kind, set.name, SortedEntry.new(set, score, value))
96
+ end
97
+ end
98
+
99
+ def sorted_set_for(kind)
100
+ case kind
101
+ when 'retry' then RetrySet.new
102
+ when 'scheduled' then ScheduledSet.new
103
+ when 'dead' then DeadSet.new
104
+ end
105
+ end
106
+
107
+ def queue_row(name, record)
108
+ {
109
+ kind: 'queue',
110
+ name: name,
111
+ jid: record.jid,
112
+ klass: record.display_class,
113
+ args: record.display_args,
114
+ queue: record.queue,
115
+ enqueued_at: record.enqueued_at&.to_f,
116
+ created_at: record.created_at&.to_f
117
+ }
118
+ end
119
+
120
+ def sorted_row(kind, name, entry)
121
+ {
122
+ kind: kind,
123
+ name: name,
124
+ jid: entry.jid,
125
+ klass: entry.display_class,
126
+ args: entry.display_args,
127
+ queue: entry.queue,
128
+ enqueued_at: entry.enqueued_at&.to_f,
129
+ created_at: entry.created_at&.to_f,
130
+ score: entry.score,
131
+ at: entry.at.to_f,
132
+ error_class: entry['error_class'],
133
+ error_message: entry['error_message'],
134
+ retry_count: entry['retry_count']
135
+ }
136
+ end
137
+ end
138
+ end
139
+ end
data/lib/wurk/web.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'web/config'
4
+ require_relative 'web/search'
5
+ require_relative 'web/enterprise'
6
+
7
+ module Wurk
8
+ # Web UI namespace. Holds three sibling concerns:
9
+ #
10
+ # * `Wurk::Web::Config` + `Wurk::Web::Authorization` — the Rack-level
11
+ # authorization hook (sidekiq-ent §9.2), 403 on falsey.
12
+ # * `Wurk::Web::Search` — Pro substring search across queues/retries/
13
+ # scheduled/dead (ZSCAN + glob; sidekiq-pro §10.1).
14
+ # * `Wurk::Web::Enterprise` — Limits / Periodic / Historical helpers
15
+ # (sidekiq-ent §9.1) used by the JSON APIs.
16
+ #
17
+ # Wurk ships every Pro/Ent web feature free. Loading this file is enough
18
+ # to make `Wurk::Web.configure` available — no separate require needed.
19
+ #
20
+ # The class body is intentionally empty — every concern lives in a sibling
21
+ # file already required above. Rubocop's Lint/EmptyClass is disabled
22
+ # because this is a namespace anchor, not a missing-implementation bug.
23
+ class Web # rubocop:disable Lint/EmptyClass
24
+ end
25
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ # Live snapshot of currently-executing jobs across the cluster. Reads
5
+ # `<identity>:work` HASH per registered process; each field is a thread
6
+ # id → JSON payload. The data lags reality by up to one heartbeat (10s)
7
+ # since heartbeats `UNLINK` and rewrite the hash atomically.
8
+ #
9
+ # Wire-compat is sacred — every Redis call matches Sidekiq OSS exactly.
10
+ # Spec: docs/target/sidekiq-free.md §19.7.
11
+ class WorkSet
12
+ include Enumerable
13
+
14
+ # Optional `processes_key:` allows tests to operate on a namespaced
15
+ # SET; production callers always use `Keys::PROCESSES` (wire-compat).
16
+ def initialize(processes_key: Keys::PROCESSES)
17
+ @processes_key = processes_key
18
+ end
19
+
20
+ # Pipelined `<identity>:work` HGETALL per known process. Yields
21
+ # (process_id, thread_id, Work). Result sorted by `run_at` so the
22
+ # oldest in-flight job appears first — dashboards rely on this order.
23
+ def each
24
+ return enum_for(:each) unless block_given?
25
+
26
+ collect_rows.sort_by { |(_, _, work)| work.run_at }.each { |row| yield(*row) }
27
+ end
28
+
29
+ # Sum of `busy` HASH field across every known identity. Lagged by one
30
+ # heartbeat. Pipelined HGET — unbounded by process count but each
31
+ # call is O(1) on the Redis side.
32
+ def size
33
+ Wurk.redis do |conn|
34
+ procs = conn.call('SMEMBERS', @processes_key)
35
+ next 0 if procs.empty?
36
+
37
+ conn.pipelined do |pipe|
38
+ procs.each { |key| pipe.call('HGET', key, 'busy') }
39
+ end.sum(&:to_i)
40
+ end
41
+ end
42
+
43
+ # O(n) scan for a JID across all in-flight jobs. Returns nil when no
44
+ # match. Slow — not for app logic. Aliased as `find_work_by_jid` for
45
+ # Sidekiq wire-compat.
46
+ def find_work(jid)
47
+ each do |_process_id, _thread_id, work|
48
+ return work if work.job.jid == jid
49
+ end
50
+ nil
51
+ end
52
+ alias find_work_by_jid find_work
53
+
54
+ private
55
+
56
+ def collect_rows
57
+ procs, all_works = fetch_work_hashes
58
+ procs.zip(all_works).flat_map do |key, workers|
59
+ rows_for(key, workers)
60
+ end
61
+ end
62
+
63
+ def fetch_work_hashes
64
+ Wurk.redis do |conn|
65
+ ids = conn.call('SMEMBERS', @processes_key).sort
66
+ next [[], []] if ids.empty?
67
+
68
+ works = conn.pipelined do |pipe|
69
+ ids.each { |id| pipe.call('HGETALL', "#{id}:work") }
70
+ end
71
+ [ids, works]
72
+ end
73
+ end
74
+
75
+ def rows_for(key, workers)
76
+ workers.filter_map do |tid, json|
77
+ next nil if json.nil? || json.empty?
78
+
79
+ [key, tid, Work.new(key, tid, Wurk.load_json(json))]
80
+ end
81
+ end
82
+ end
83
+
84
+ # One in-flight job. The `payload` field is the raw JSON the processor
85
+ # is currently executing; `job` lazily wraps it in a JobRecord so
86
+ # downstream code can read class/args/jid without re-parsing.
87
+ #
88
+ # Spec: docs/target/sidekiq-free.md §19.7.
89
+ class Work
90
+ attr_reader :process_id, :thread_id
91
+
92
+ def initialize(pid, tid, hsh)
93
+ @process_id = pid
94
+ @thread_id = tid
95
+ @hsh = hsh
96
+ end
97
+
98
+ def queue = @hsh['queue']
99
+ def payload = @hsh['payload']
100
+
101
+ # Float epoch seconds → Time. Heartbeat writes `run_at` as Float, so
102
+ # we don't have to handle the dual ms/secs format JobRecord does.
103
+ def run_at
104
+ ::Time.at(@hsh['run_at'])
105
+ end
106
+
107
+ def job
108
+ @job ||= JobRecord.new(payload)
109
+ end
110
+ end
111
+
112
+ # Deprecated alias. Sidekiq <8 used `Workers` for what is now `WorkSet`;
113
+ # third-party gems may still reference it. Resolved at load time so
114
+ # the alias survives a constant lookup by either name.
115
+ Workers = WorkSet
116
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../job_util'
4
+
5
+ module Wurk
6
+ module Worker
7
+ # Aliased as `Sidekiq::Job::Setter`. Per-call option carrier returned
8
+ # by `Worker.set(opts)`. Holds string-keyed overrides and exposes the
9
+ # same `perform_*` surface as the worker class itself.
10
+ #
11
+ # `set(sync: true)` makes `perform_async` invoke `perform_inline` —
12
+ # required for testing-mode parity.
13
+ #
14
+ # Spec: docs/target/sidekiq-free.md §6.3 (Sidekiq::Job::Setter).
15
+ class Setter
16
+ include Wurk::JobUtil
17
+
18
+ def initialize(klass, opts)
19
+ @klass = klass
20
+ @opts = normalize_opts(opts)
21
+ end
22
+
23
+ def set(options)
24
+ @opts.merge!(normalize_opts(options))
25
+ self
26
+ end
27
+
28
+ def perform_async(*args)
29
+ return perform_inline(*args) if @opts['sync']
30
+
31
+ @klass.client_push(@opts.merge('class' => @klass, 'args' => args))
32
+ end
33
+
34
+ def perform_inline(*)
35
+ @klass.new.perform(*)
36
+ end
37
+ alias perform_sync perform_inline
38
+
39
+ def perform_in(interval, *args)
40
+ ts = absolute_at(interval)
41
+ item = @opts.merge('class' => @klass, 'args' => args)
42
+ item['at'] = ts if ts && ts > now_seconds
43
+ @klass.client_push(item)
44
+ end
45
+ alias perform_at perform_in
46
+
47
+ def perform_bulk(args, **opts)
48
+ merged = @opts.merge(opts.transform_keys(&:to_s)).merge(
49
+ 'class' => @klass,
50
+ 'args' => args
51
+ )
52
+ @klass.build_client.push_bulk(merged)
53
+ end
54
+
55
+ private
56
+
57
+ def absolute_at(interval)
58
+ unless interval.is_a?(Numeric) || interval.is_a?(Time)
59
+ raise ArgumentError, "interval must be Numeric or Time, got #{interval.class}"
60
+ end
61
+
62
+ seconds = interval.to_f
63
+ seconds < Wurk::Worker::SCHEDULED_THRESHOLD ? now_seconds + seconds : seconds
64
+ end
65
+
66
+ def now_seconds
67
+ ::Process.clock_gettime(::Process::CLOCK_REALTIME)
68
+ end
69
+
70
+ # Lifts wait/wait_until → at; stringifies keys. Matches Sidekiq's
71
+ # Setter initializer normalization exactly.
72
+ def normalize_opts(opts)
73
+ result = {}
74
+ opts.each do |k, v|
75
+ key = k.to_s
76
+ case key
77
+ when 'wait', 'wait_until' then result['at'] = wait_to_seconds(v)
78
+ else result[key] = v
79
+ end
80
+ end
81
+ result
82
+ end
83
+
84
+ def wait_to_seconds(value)
85
+ case value
86
+ when Time then value.to_f
87
+ when Numeric then ::Process.clock_gettime(::Process::CLOCK_REALTIME) + value.to_f
88
+ else raise ArgumentError, "wait/wait_until must be Numeric or Time, got #{value.class}"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end