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,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