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,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../middleware'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
module Middleware
|
|
7
|
+
# Propagates `I18n.locale` from the enqueueing process to the worker.
|
|
8
|
+
# Auto-registered when this file is required: Client onto the
|
|
9
|
+
# `client_middleware` chain, Server onto `server_middleware`.
|
|
10
|
+
#
|
|
11
|
+
# No-op when the `I18n` constant is undefined (no `i18n` gem loaded);
|
|
12
|
+
# the middleware survives the absence and just passes the job through.
|
|
13
|
+
#
|
|
14
|
+
# Job hash key: `"locale"` (matches Sidekiq spec §2.2).
|
|
15
|
+
module I18n
|
|
16
|
+
class << self
|
|
17
|
+
def current_locale
|
|
18
|
+
::I18n.locale.to_s if defined?(::I18n)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def with_locale(locale)
|
|
22
|
+
return yield unless defined?(::I18n) && locale
|
|
23
|
+
|
|
24
|
+
previous = ::I18n.locale
|
|
25
|
+
::I18n.locale = locale
|
|
26
|
+
begin
|
|
27
|
+
yield
|
|
28
|
+
ensure
|
|
29
|
+
::I18n.locale = previous
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Client: writes the current locale into the outgoing job hash before
|
|
35
|
+
# push. Idempotent — a caller-supplied `"locale"` is preserved.
|
|
36
|
+
# No-op when I18n isn't loaded (don't introduce a nil `"locale"` key:
|
|
37
|
+
# adding it would change the wire shape for callers without I18n).
|
|
38
|
+
class Client
|
|
39
|
+
include Wurk::Middleware::ClientMiddleware
|
|
40
|
+
|
|
41
|
+
def call(_job_class, job, _queue, _redis_pool)
|
|
42
|
+
current = I18n.current_locale
|
|
43
|
+
job['locale'] ||= current if current
|
|
44
|
+
yield
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Server: scopes the job execution to the captured locale. Restores
|
|
49
|
+
# the previous value on the way out so middleware stacking doesn't
|
|
50
|
+
# leak across jobs in the same thread.
|
|
51
|
+
class Server
|
|
52
|
+
include Wurk::Middleware::ServerMiddleware
|
|
53
|
+
|
|
54
|
+
def call(_job_instance, job, _queue, &)
|
|
55
|
+
I18n.with_locale(job['locale'], &)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Wurk.configuration.client_middleware.add(Wurk::Middleware::I18n::Client)
|
|
63
|
+
Wurk.configuration.server_middleware.add(Wurk::Middleware::I18n::Server)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../middleware'
|
|
4
|
+
require_relative '../job'
|
|
5
|
+
require_relative '../job_retry'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
module Middleware
|
|
9
|
+
# Server middleware. Catches `Wurk::Job::Interrupted` raised by an
|
|
10
|
+
# IterableJob mid-iteration (or any cooperatively-cancelled job),
|
|
11
|
+
# re-pushes the job to the head of its queue so it resumes from the
|
|
12
|
+
# persisted cursor, and raises `Wurk::JobRetry::Skip` so the retry
|
|
13
|
+
# layer treats this as a clean exit rather than an error.
|
|
14
|
+
#
|
|
15
|
+
# The re-push uses LPUSH (head of queue) so the same job is the next
|
|
16
|
+
# one to be fetched after restart. The job JSON is unchanged: cursor
|
|
17
|
+
# state lives in the `it-<jid>` HASH (see IterableJob persistence),
|
|
18
|
+
# not in the payload.
|
|
19
|
+
#
|
|
20
|
+
# Auto-registered at the top of the server chain when this file is
|
|
21
|
+
# required. Top-of-chain is important: a downstream middleware must
|
|
22
|
+
# not swallow the Interrupted before we see it.
|
|
23
|
+
#
|
|
24
|
+
# Spec: docs/target/sidekiq-free.md §10.3.
|
|
25
|
+
class InterruptHandler
|
|
26
|
+
include Wurk::Middleware::ServerMiddleware
|
|
27
|
+
|
|
28
|
+
def call(_job_instance, job, queue)
|
|
29
|
+
yield
|
|
30
|
+
rescue Wurk::Job::Interrupted
|
|
31
|
+
repush(job, queue)
|
|
32
|
+
raise Wurk::JobRetry::Skip
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def repush(job, queue)
|
|
38
|
+
payload = Wurk.dump_json(job)
|
|
39
|
+
redis_pool.with { |conn| conn.call('LPUSH', "queue:#{queue}", payload) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Wurk.configuration.server_middleware.prepend(Wurk::Middleware::InterruptHandler)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../middleware'
|
|
5
|
+
require_relative '../metrics/statsd'
|
|
6
|
+
require_relative '../keys'
|
|
7
|
+
require_relative '../dead_set'
|
|
8
|
+
|
|
9
|
+
module Wurk
|
|
10
|
+
module Middleware
|
|
11
|
+
# Pro parity (§3.2): poison-pill detection for reliable-fetch orphans.
|
|
12
|
+
# When a job is recovered out of a dead process's private list, we
|
|
13
|
+
# INCR a per-jid counter at `super_fetch:recovered:<jid>` with a 72h
|
|
14
|
+
# TTL. Once the counter crosses RECOVERY_THRESHOLD (3) the next recovery
|
|
15
|
+
# is treated as a poison pill: the payload is moved to the dead set,
|
|
16
|
+
# `jobs.poison` is emitted to statsd, and recovery callbacks fire so
|
|
17
|
+
# operators can be paged.
|
|
18
|
+
#
|
|
19
|
+
# Counter key TTL is wire-compat with Sidekiq Pro — third-party tooling
|
|
20
|
+
# that watches `super_fetch:recovered:*` expects 72h.
|
|
21
|
+
#
|
|
22
|
+
# No server-middleware registration: callers are reaper / bulk_requeue
|
|
23
|
+
# paths that drive the lifecycle directly via `track!(payload, queue:)`.
|
|
24
|
+
# When that integration lands, it just calls `PoisonPill.track!` on each
|
|
25
|
+
# orphan it about to RPUSH back to the public queue.
|
|
26
|
+
module PoisonPill
|
|
27
|
+
RECOVERY_THRESHOLD = 3
|
|
28
|
+
RECOVERY_TTL = 72 * 60 * 60
|
|
29
|
+
KEY_PREFIX = 'super_fetch:recovered:'
|
|
30
|
+
DEAD_RECORD_LIMIT = 100
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Called per recovered orphan job. Returns `:poison` when the threshold
|
|
35
|
+
# was crossed and the job was killed; `:recovered` when the job is
|
|
36
|
+
# being re-pushed (caller's responsibility — we don't touch the queue
|
|
37
|
+
# here). Emits `jobs.recovered.fetch` on every call, `jobs.poison`
|
|
38
|
+
# only on the kill path.
|
|
39
|
+
#
|
|
40
|
+
# @param payload [String, Hash] the job JSON or pre-parsed hash.
|
|
41
|
+
# @param queue [String, nil] the public queue name (without `queue:`).
|
|
42
|
+
# @return [Symbol] :recovered | :poison
|
|
43
|
+
def track!(payload, queue: nil)
|
|
44
|
+
job = parse(payload)
|
|
45
|
+
return :recovered unless job
|
|
46
|
+
|
|
47
|
+
jid = job['jid']
|
|
48
|
+
klass = job['class']
|
|
49
|
+
emit_recovered_fetch(klass, queue)
|
|
50
|
+
return :recovered if jid.nil? || jid.empty?
|
|
51
|
+
|
|
52
|
+
count = bump_counter(jid)
|
|
53
|
+
if count >= RECOVERY_THRESHOLD
|
|
54
|
+
mark_poison(payload, job, queue: queue, count: count)
|
|
55
|
+
:poison
|
|
56
|
+
else
|
|
57
|
+
:recovered
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Reads the current recovery counter without bumping it. Used by tests
|
|
62
|
+
# and dashboards; returns 0 for jobs that have never been recovered.
|
|
63
|
+
def recovery_count(jid)
|
|
64
|
+
return 0 if jid.nil? || jid.to_s.empty?
|
|
65
|
+
|
|
66
|
+
Wurk.redis { |conn| conn.call('GET', "#{KEY_PREFIX}#{jid}") }.to_i
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resets the counter for a jid — call after a successful perform so a
|
|
70
|
+
# job that recovered twice and then completed doesn't accumulate state.
|
|
71
|
+
def clear!(jid)
|
|
72
|
+
return if jid.nil? || jid.to_s.empty?
|
|
73
|
+
|
|
74
|
+
Wurk.redis { |conn| conn.call('DEL', "#{KEY_PREFIX}#{jid}") }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Register a callback fired when a poison pill is detected. Callbacks
|
|
78
|
+
# receive a single Hash `{jid:, klass:, count:, queue:}` — matches
|
|
79
|
+
# Sidekiq Pro's documented shape so consumers can drop in unchanged.
|
|
80
|
+
def on_poison(&block)
|
|
81
|
+
raise ArgumentError, 'block required' unless block
|
|
82
|
+
|
|
83
|
+
callbacks << block
|
|
84
|
+
block
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def callbacks
|
|
88
|
+
@callbacks ||= []
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Test-only reset.
|
|
92
|
+
def reset!
|
|
93
|
+
@callbacks = []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ---- internals --------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def parse(payload)
|
|
99
|
+
case payload
|
|
100
|
+
when Hash then payload
|
|
101
|
+
when String
|
|
102
|
+
begin
|
|
103
|
+
Wurk.load_json(payload)
|
|
104
|
+
rescue ::JSON::ParserError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def bump_counter(jid)
|
|
111
|
+
key = "#{KEY_PREFIX}#{jid}"
|
|
112
|
+
Wurk.redis do |conn|
|
|
113
|
+
count = conn.call('INCR', key).to_i
|
|
114
|
+
conn.call('EXPIRE', key, RECOVERY_TTL)
|
|
115
|
+
count
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def emit_recovered_fetch(klass, queue)
|
|
120
|
+
tags = []
|
|
121
|
+
tags << "class:#{klass}" if klass
|
|
122
|
+
tags << "queue:#{queue}" if queue
|
|
123
|
+
Wurk::Metrics::Statsd.increment('jobs.recovered.fetch', tags: tags.empty? ? nil : tags)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def mark_poison(payload, job, queue:, count:)
|
|
127
|
+
emit_poison(job['class'], queue)
|
|
128
|
+
json = payload.is_a?(String) ? payload : Wurk.dump_json(job)
|
|
129
|
+
Wurk::DeadSet.new.kill(json, notify_failure: false)
|
|
130
|
+
fire_callbacks(jid: job['jid'], klass: job['class'], count: count, queue: queue)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def emit_poison(klass, queue)
|
|
134
|
+
tags = []
|
|
135
|
+
tags << "class:#{klass}" if klass
|
|
136
|
+
tags << "queue:#{queue}" if queue
|
|
137
|
+
Wurk::Metrics::Statsd.increment('jobs.poison', tags: tags.empty? ? nil : tags)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def fire_callbacks(pill)
|
|
141
|
+
callbacks.each do |cb|
|
|
142
|
+
cb.call(pill)
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
Wurk.configuration.handle_exception(e, context: 'Wurk::Middleware::PoisonPill')
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
# Sidekiq-compatible middleware contract. Both client and server middleware
|
|
5
|
+
# share the same surface (Sidekiq aliases `ClientMiddleware = ServerMiddleware`).
|
|
6
|
+
# Including this gives a middleware class a `config` setter (the Chain assigns
|
|
7
|
+
# it via `Entry#make_new`) plus convenience accessors to the bound config's
|
|
8
|
+
# Redis pool and logger.
|
|
9
|
+
#
|
|
10
|
+
# `config` is either a Wurk::Configuration or a Wurk::Capsule — both expose
|
|
11
|
+
# `redis_pool`, `redis`, `logger`. Treat config as the single seam between
|
|
12
|
+
# middleware and the host process.
|
|
13
|
+
#
|
|
14
|
+
# Spec: docs/target/sidekiq-free.md §10.2.
|
|
15
|
+
module Middleware
|
|
16
|
+
module ServerMiddleware
|
|
17
|
+
attr_accessor :config
|
|
18
|
+
|
|
19
|
+
def redis_pool
|
|
20
|
+
config.redis_pool
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def logger
|
|
24
|
+
config.logger
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def redis(&)
|
|
28
|
+
config.redis(&)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
ClientMiddleware = ServerMiddleware
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
# Live process list view backed by the `processes` SET + per-identity hashes.
|
|
5
|
+
# Cluster topology lives here: dashboard process list, total concurrency
|
|
6
|
+
# gauge, RSS roll-up, leader lookup.
|
|
7
|
+
#
|
|
8
|
+
# Wire-compat is sacred — every Redis call matches Sidekiq OSS exactly.
|
|
9
|
+
# Spec: docs/target/sidekiq-free.md §1 (Redis schema), §19.6.
|
|
10
|
+
class ProcessSet
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
# Order matters — index-aligned with the HMGET row.
|
|
14
|
+
BEAT_FIELDS = %w[busy beat quiet rss rtt_us].freeze
|
|
15
|
+
EACH_FIELDS = (%w[info concurrency] + BEAT_FIELDS).freeze
|
|
16
|
+
LOOKUP_FIELDS = (%w[info] + BEAT_FIELDS).freeze
|
|
17
|
+
private_constant :BEAT_FIELDS, :EACH_FIELDS, :LOOKUP_FIELDS
|
|
18
|
+
|
|
19
|
+
# `processes` SET only stores identity strings; the live hash at each
|
|
20
|
+
# identity expires every 60s, so SCARD can lag reality. Constructor
|
|
21
|
+
# opts into a `cleanup` (rate-limited globally to 1/min) so the size
|
|
22
|
+
# reported below reflects the post-prune state — except when caller
|
|
23
|
+
# explicitly skips it (e.g. inside `cleanup` itself, or for snapshot
|
|
24
|
+
# reads on hot paths).
|
|
25
|
+
#
|
|
26
|
+
# Positional Boolean matches Sidekiq's public API — wire-compat sacred.
|
|
27
|
+
def initialize(clean_plz = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
28
|
+
cleanup if clean_plz
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fetch a single Process by identity. Pipelined SISMEMBER + HMGET so
|
|
32
|
+
# absence (process never registered) and expiry (heartbeat lapsed,
|
|
33
|
+
# info field gone) both return nil.
|
|
34
|
+
def self.[](identity)
|
|
35
|
+
exists, fields = Wurk.redis do |conn|
|
|
36
|
+
conn.pipelined do |pipe|
|
|
37
|
+
pipe.call('SISMEMBER', Keys::PROCESSES, identity)
|
|
38
|
+
pipe.call('HMGET', identity, *LOOKUP_FIELDS)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
return nil if exists.to_i.zero? || fields.first.nil?
|
|
42
|
+
|
|
43
|
+
build_process(LOOKUP_FIELDS.zip(fields).to_h)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# SREM identities whose `info` HASH field has expired. Rate-limited
|
|
47
|
+
# globally to 1/min via SET NX EX so concurrent dashboards / API calls
|
|
48
|
+
# don't dogpile the prune. Returns the number of identities removed
|
|
49
|
+
# (or 0 when the lock was held by someone else).
|
|
50
|
+
#
|
|
51
|
+
# Spec: docs/target/sidekiq-free.md §31.17.
|
|
52
|
+
def cleanup
|
|
53
|
+
return 0 unless acquired_cleanup_lock?
|
|
54
|
+
|
|
55
|
+
Wurk.redis do |conn|
|
|
56
|
+
procs = conn.call('SMEMBERS', Keys::PROCESSES)
|
|
57
|
+
next 0 if procs.empty?
|
|
58
|
+
|
|
59
|
+
heartbeats = conn.pipelined do |pipe|
|
|
60
|
+
procs.each { |key| pipe.call('HGET', key, 'info') }
|
|
61
|
+
end
|
|
62
|
+
to_prune = procs.zip(heartbeats).filter_map { |id, info| id if info.nil? }
|
|
63
|
+
next 0 if to_prune.empty?
|
|
64
|
+
|
|
65
|
+
conn.call('SREM', Keys::PROCESSES, *to_prune).to_i
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Pipelined identity → HMGET. Skips identities whose `info` field is
|
|
70
|
+
# gone (heartbeat expired between SMEMBERS and HMGET). Sorted by
|
|
71
|
+
# identity so iteration order is stable for dashboards.
|
|
72
|
+
def each
|
|
73
|
+
return enum_for(:each) unless block_given?
|
|
74
|
+
|
|
75
|
+
rows = fetch_each_rows
|
|
76
|
+
rows.each do |row|
|
|
77
|
+
attrs = EACH_FIELDS.zip(row).to_h
|
|
78
|
+
next if attrs['info'].nil?
|
|
79
|
+
|
|
80
|
+
yield ProcessSet.send(:build_process, attrs)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# SCARD over `processes`. Not pruned — may include identities whose
|
|
85
|
+
# heartbeat has lapsed. Use `each` for the accurate count.
|
|
86
|
+
def size
|
|
87
|
+
Wurk.redis { |conn| conn.call('SCARD', Keys::PROCESSES) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Sum of `concurrency` across live processes. Iterates `each` so dead
|
|
91
|
+
# identities are skipped.
|
|
92
|
+
def total_concurrency
|
|
93
|
+
sum { |p| p['concurrency'].to_i }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Sum of `rss` (KB) across live processes.
|
|
97
|
+
def total_rss_in_kb
|
|
98
|
+
sum { |p| p['rss'].to_i }
|
|
99
|
+
end
|
|
100
|
+
alias total_rss total_rss_in_kb
|
|
101
|
+
|
|
102
|
+
# Cluster leader identity. Ent-only feature; OSS only reads the key.
|
|
103
|
+
# Memoized per-instance: dashboards re-instantiate ProcessSet per render.
|
|
104
|
+
# `||=` with empty-string fallback distinguishes "leader is unset" from
|
|
105
|
+
# "memoization not yet computed".
|
|
106
|
+
def leader
|
|
107
|
+
@leader ||= Wurk.redis { |c| c.call('GET', 'dear-leader') } || ''
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
class << self
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Builds a Wurk::Process from `{field_name => raw_value}`. Handles
|
|
114
|
+
# both the 6-field `[]` shape and the 7-field `each` shape — extra
|
|
115
|
+
# `concurrency` from the heartbeat overrides whatever `info` JSON
|
|
116
|
+
# claimed (heartbeat is fresher).
|
|
117
|
+
def build_process(attrs)
|
|
118
|
+
hash = Wurk.load_json(attrs.fetch('info')).merge(beat_overrides(attrs))
|
|
119
|
+
hash['concurrency'] = attrs['concurrency'].to_i if attrs.key?('concurrency')
|
|
120
|
+
Process.new(hash)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def beat_overrides(attrs)
|
|
124
|
+
{
|
|
125
|
+
'busy' => attrs['busy'].to_i,
|
|
126
|
+
'beat' => attrs['beat'].to_f,
|
|
127
|
+
'quiet' => attrs['quiet'],
|
|
128
|
+
'rss' => attrs['rss'].to_i,
|
|
129
|
+
'rtt_us' => attrs['rtt_us'].to_i
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def fetch_each_rows
|
|
137
|
+
Wurk.redis do |conn|
|
|
138
|
+
procs = conn.call('SMEMBERS', Keys::PROCESSES).sort
|
|
139
|
+
next [] if procs.empty?
|
|
140
|
+
|
|
141
|
+
conn.pipelined do |pipe|
|
|
142
|
+
procs.each { |key| pipe.call('HMGET', key, *EACH_FIELDS) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def acquired_cleanup_lock?
|
|
148
|
+
Wurk.redis { |conn| conn.call('SET', 'process_cleanup', '1', 'NX', 'EX', '60') } == 'OK'
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# One row in the process list. `attribs` is the merged `info` JSON +
|
|
153
|
+
# per-beat fields (busy, beat, quiet, rss, rtt_us). All fields are
|
|
154
|
+
# read-only — mutations happen via signals (`<id>-signals` LIST), not
|
|
155
|
+
# by editing this hash.
|
|
156
|
+
#
|
|
157
|
+
# Spec: docs/target/sidekiq-free.md §19.6.
|
|
158
|
+
class Process
|
|
159
|
+
def initialize(hash)
|
|
160
|
+
@attribs = hash
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def tag = self['tag']
|
|
164
|
+
def labels = self['labels'].to_a
|
|
165
|
+
def [](key) = @attribs[key]
|
|
166
|
+
def identity = self['identity']
|
|
167
|
+
alias id identity
|
|
168
|
+
|
|
169
|
+
# Back-compat for dashboards predating capsules (pre-v8.0.8). New
|
|
170
|
+
# heartbeats write `capsules`; fall through to the legacy `queues`
|
|
171
|
+
# field if the process hasn't been upgraded yet.
|
|
172
|
+
def queues
|
|
173
|
+
if self['capsules']
|
|
174
|
+
capsules.values.flat_map { |c| c['weights'].keys }.uniq
|
|
175
|
+
else
|
|
176
|
+
self['queues']
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Back-compat sibling of `queues`. Two capsules processing the same
|
|
181
|
+
# queue name will collapse to a single weight — there's no useful way
|
|
182
|
+
# to merge them and the dashboard never displayed both.
|
|
183
|
+
def weights
|
|
184
|
+
return self['weights'] unless self['capsules']
|
|
185
|
+
|
|
186
|
+
capsules.values.each_with_object({}) do |cap, acc|
|
|
187
|
+
cap['weights'].each { |q, w| acc[q] = w }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def capsules = self['capsules']
|
|
192
|
+
def version = self['version']
|
|
193
|
+
def embedded? = self['embedded']
|
|
194
|
+
|
|
195
|
+
# SIGTSTP — drop fetch, drain in-flight. Asynchronous; takes one
|
|
196
|
+
# heartbeat (≤10s) for the target process to notice.
|
|
197
|
+
def quiet!
|
|
198
|
+
raise 'Cannot quiet an embedded process' if embedded?
|
|
199
|
+
|
|
200
|
+
signal('TSTP')
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# SIGTERM — graceful shutdown within `shutdown_timeout`. Asynchronous.
|
|
204
|
+
def stop!
|
|
205
|
+
raise 'Cannot stop an embedded process' if embedded?
|
|
206
|
+
|
|
207
|
+
signal('TERM')
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# SIGTTIN — dump every thread's backtrace. For diagnosing frozen
|
|
211
|
+
# processes that are still beating.
|
|
212
|
+
def dump_threads
|
|
213
|
+
signal('TTIN')
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Truthy when this process has accepted a TSTP and won't fetch new
|
|
217
|
+
# jobs. The heartbeat writes `quiet = "true"` after handling the
|
|
218
|
+
# signal — read-only here.
|
|
219
|
+
def stopping?
|
|
220
|
+
self['quiet'] == 'true'
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Compares identity against the `dear-leader` STRING. Ent-only;
|
|
224
|
+
# always false in OSS/free.
|
|
225
|
+
def leader?
|
|
226
|
+
Wurk.redis { |c| c.call('GET', 'dear-leader') == identity }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# LPUSH to `<identity>-signals` + EXPIRE 60s. Pipelined so a crashed
|
|
232
|
+
# caller can't leave a stray key behind without a TTL.
|
|
233
|
+
def signal(sig)
|
|
234
|
+
key = "#{identity}-signals"
|
|
235
|
+
Wurk.redis do |c|
|
|
236
|
+
c.pipelined do |pipe|
|
|
237
|
+
pipe.call('LPUSH', key, sig)
|
|
238
|
+
pipe.call('EXPIRE', key, 60)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|