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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/SECURITY.md +39 -0
- data/app/controllers/wurk/api/pagination.rb +67 -0
- data/app/controllers/wurk/api/serializers.rb +131 -0
- data/app/controllers/wurk/api_controller.rb +248 -0
- data/app/controllers/wurk/application_controller.rb +7 -0
- data/app/controllers/wurk/dashboard_controller.rb +48 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +34 -0
- data/exe/wurk +22 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
- data/lib/generators/wurk/install/install_generator.rb +22 -0
- data/lib/generators/wurk/install/templates/wurk.rb +16 -0
- data/lib/wurk/active_job/wrapper.rb +32 -0
- data/lib/wurk/api/fast.rb +78 -0
- data/lib/wurk/batch/buffer.rb +26 -0
- data/lib/wurk/batch/callback_job.rb +37 -0
- data/lib/wurk/batch/callbacks.rb +176 -0
- data/lib/wurk/batch/client_middleware.rb +27 -0
- data/lib/wurk/batch/death_handler.rb +39 -0
- data/lib/wurk/batch/empty.rb +21 -0
- data/lib/wurk/batch/server_middleware.rb +62 -0
- data/lib/wurk/batch/status.rb +140 -0
- data/lib/wurk/batch.rb +351 -0
- data/lib/wurk/batch_set.rb +67 -0
- data/lib/wurk/capsule.rb +176 -0
- data/lib/wurk/cli.rb +349 -0
- data/lib/wurk/client/buffered.rb +372 -0
- data/lib/wurk/client.rb +330 -0
- data/lib/wurk/compat.rb +136 -0
- data/lib/wurk/component.rb +136 -0
- data/lib/wurk/configuration.rb +373 -0
- data/lib/wurk/context.rb +35 -0
- data/lib/wurk/cron.rb +636 -0
- data/lib/wurk/dashboard_manifest.rb +39 -0
- data/lib/wurk/dead_set.rb +78 -0
- data/lib/wurk/deploy.rb +91 -0
- data/lib/wurk/embedded.rb +94 -0
- data/lib/wurk/encryption.rb +276 -0
- data/lib/wurk/engine.rb +81 -0
- data/lib/wurk/fetcher/reaper.rb +264 -0
- data/lib/wurk/fetcher/reliable.rb +138 -0
- data/lib/wurk/fetcher.rb +11 -0
- data/lib/wurk/health.rb +193 -0
- data/lib/wurk/heartbeat.rb +211 -0
- data/lib/wurk/iterable_job.rb +292 -0
- data/lib/wurk/job/options.rb +70 -0
- data/lib/wurk/job.rb +33 -0
- data/lib/wurk/job_logger.rb +68 -0
- data/lib/wurk/job_record.rb +156 -0
- data/lib/wurk/job_retry.rb +320 -0
- data/lib/wurk/job_set.rb +212 -0
- data/lib/wurk/job_util.rb +162 -0
- data/lib/wurk/keys.rb +52 -0
- data/lib/wurk/launcher.rb +289 -0
- data/lib/wurk/leader.rb +221 -0
- data/lib/wurk/limiter/base.rb +138 -0
- data/lib/wurk/limiter/bucket.rb +80 -0
- data/lib/wurk/limiter/concurrent.rb +132 -0
- data/lib/wurk/limiter/leaky.rb +91 -0
- data/lib/wurk/limiter/points.rb +89 -0
- data/lib/wurk/limiter/server_middleware.rb +77 -0
- data/lib/wurk/limiter/unlimited.rb +48 -0
- data/lib/wurk/limiter/window.rb +80 -0
- data/lib/wurk/limiter.rb +255 -0
- data/lib/wurk/logger.rb +81 -0
- data/lib/wurk/lua/loader.rb +53 -0
- data/lib/wurk/lua.rb +187 -0
- data/lib/wurk/manager.rb +132 -0
- data/lib/wurk/metrics/history.rb +151 -0
- data/lib/wurk/metrics/query.rb +173 -0
- data/lib/wurk/metrics/rollup.rb +169 -0
- data/lib/wurk/metrics/statsd.rb +197 -0
- data/lib/wurk/metrics.rb +7 -0
- data/lib/wurk/middleware/chain.rb +128 -0
- data/lib/wurk/middleware/current_attributes.rb +87 -0
- data/lib/wurk/middleware/expiry.rb +50 -0
- data/lib/wurk/middleware/i18n.rb +63 -0
- data/lib/wurk/middleware/interrupt_handler.rb +45 -0
- data/lib/wurk/middleware/poison_pill.rb +149 -0
- data/lib/wurk/middleware.rb +34 -0
- data/lib/wurk/process_set.rb +243 -0
- data/lib/wurk/processor.rb +247 -0
- data/lib/wurk/queue.rb +108 -0
- data/lib/wurk/queues.rb +80 -0
- data/lib/wurk/rails.rb +9 -0
- data/lib/wurk/railtie.rb +28 -0
- data/lib/wurk/redis_pool.rb +79 -0
- data/lib/wurk/retry_set.rb +17 -0
- data/lib/wurk/scheduled.rb +189 -0
- data/lib/wurk/scheduled_set.rb +18 -0
- data/lib/wurk/sorted_entry.rb +95 -0
- data/lib/wurk/stats.rb +190 -0
- data/lib/wurk/swarm/child_boot.rb +105 -0
- data/lib/wurk/swarm.rb +260 -0
- data/lib/wurk/testing.rb +102 -0
- data/lib/wurk/topology.rb +74 -0
- data/lib/wurk/unique.rb +240 -0
- data/lib/wurk/version.rb +5 -0
- data/lib/wurk/web/config.rb +180 -0
- data/lib/wurk/web/enterprise.rb +138 -0
- data/lib/wurk/web/search.rb +139 -0
- data/lib/wurk/web.rb +25 -0
- data/lib/wurk/work_set.rb +116 -0
- data/lib/wurk/worker/setter.rb +93 -0
- data/lib/wurk/worker.rb +216 -0
- data/lib/wurk.rb +238 -0
- data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
- data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
- data/vendor/assets/dashboard/index.html +13 -0
- data/vendor/assets/dashboard/wurk-manifest.json +4 -0
- 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
|