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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../worker'
4
+
5
+ module Wurk
6
+ class Batch
7
+ # Worker that runs a single batch callback. Target may be a Class name
8
+ # ("MyCallback" → `MyCallback.new.on_<event>(status, options)`) or a
9
+ # "Klass#method" spec ("Foo#bar" → `Foo.new.bar(status, options)`).
10
+ # Failures retry like any ordinary job — callbacks MUST be idempotent.
11
+ #
12
+ # Spec: docs/target/sidekiq-pro.md §2.4.
13
+ class CallbackJob
14
+ include Wurk::Job
15
+
16
+ sidekiq_options retry: true
17
+
18
+ def perform(bid, target_spec, event, options)
19
+ klass, method = resolve(target_spec, event)
20
+ instance = klass.new
21
+ status = Wurk::Batch::Status.new(bid)
22
+ instance.public_send(method, status, options || {})
23
+ end
24
+
25
+ private
26
+
27
+ def resolve(spec, event)
28
+ if spec.include?('#')
29
+ klass_name, method_name = spec.split('#', 2)
30
+ [Object.const_get(klass_name), method_name.to_sym]
31
+ else
32
+ [Object.const_get(spec), :"on_#{event}"]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Wurk
6
+ class Batch
7
+ # Fires batch callbacks (`:success`, `:complete`, `:death`) by enqueuing
8
+ # them as ordinary jobs on the batch's `callback_queue`. Dedup is via
9
+ # b-<bid>-notify so the same callback can't be enqueued twice even
10
+ # if multiple workers race to ack the final job.
11
+ #
12
+ # Callback wrapper job: Wurk::Batch::CallbackJob — given a target spec
13
+ # ("Klass" or "Klass#method") and options hash, it instantiates and
14
+ # invokes on_<event> (or the named method) with the Status snapshot.
15
+ module Callbacks
16
+ module_function
17
+
18
+ # Called from the server middleware after BATCH_ACK_SUCCESS. Fires
19
+ # `:complete` when live jids hit 0; fires `:success` when pending
20
+ # also hits 0 and there have been no deaths.
21
+ def maybe_fire(bid, pending:, live:)
22
+ return unless live.zero?
23
+
24
+ fire_complete(bid)
25
+ fire_success(bid) if pending.zero? && !death_fired?(bid)
26
+ propagate_to_parent(bid)
27
+ end
28
+
29
+ # Fired from Wurk::Batch::DeathHandler on the FIRST permanent death
30
+ # in the batch only. Subsequent deaths bump the counter but do not
31
+ # re-enqueue the callback.
32
+ def fire_death(bid)
33
+ return unless dedup_set(bid, 'death')
34
+
35
+ record_event(bid, 'death_at')
36
+ Wurk.redis { |conn| conn.call('ZADD', 'dead-batches', Time.now.to_f.to_s, bid) }
37
+ enqueue_callbacks(bid, 'death')
38
+ cascade_death(bid)
39
+ end
40
+
41
+ # A child's death means the parent — and every ancestor — can never
42
+ # fully succeed, so `:death` propagates up the parent chain. The
43
+ # recursion bottoms out at the root (empty parent_bid); fire_death's own
44
+ # dedup_set guard makes each ancestor's `:death` fire exactly once even
45
+ # under racing children.
46
+ def cascade_death(bid)
47
+ parent_bid = parent_bid_for(bid)
48
+ return if parent_bid.nil? || parent_bid.empty?
49
+
50
+ fire_death(parent_bid)
51
+ end
52
+
53
+ def fire_complete(bid)
54
+ return unless dedup_set(bid, 'complete')
55
+
56
+ record_event(bid, 'complete_at')
57
+ enqueue_callbacks(bid, 'complete')
58
+ end
59
+
60
+ def fire_success(bid)
61
+ return unless dedup_set(bid, 'success')
62
+
63
+ record_event(bid, 'success_at')
64
+ enqueue_callbacks(bid, 'success')
65
+ apply_linger(bid)
66
+ end
67
+
68
+ # Post-success retention: a succeeded batch no longer coordinates any
69
+ # jobs, so its keys expire after the per-batch `linger` override (else
70
+ # 24h) instead of the 30d pending TTL. Mirrors Sidekiq Pro §2.8.
71
+ def apply_linger(bid)
72
+ raw = Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'linger') }
73
+ seconds = raw.nil? || raw.to_s.empty? ? Batch::POST_SUCCESS_EXPIRY_SECONDS : raw.to_i
74
+ Wurk.redis do |conn|
75
+ Batch.keys_for(bid).each { |key| conn.call('EXPIRE', key, seconds) }
76
+ end
77
+ end
78
+
79
+ # Atomically marks `bid` as having fired `event`. Returns true the
80
+ # first time, false thereafter — caller skips the enqueue when false.
81
+ # SET NX makes this safe under racing acks.
82
+ def dedup_set(bid, event)
83
+ Wurk.redis do |conn|
84
+ ok = conn.call('SET', "b-#{bid}-#{event}", '1', 'NX', 'EX', Batch::CALLBACK_NOTIFY_TTL)
85
+ ok == 'OK'
86
+ end
87
+ end
88
+
89
+ def record_event(bid, field)
90
+ now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
91
+ Wurk.redis do |conn|
92
+ conn.call('HSET', "b-#{bid}", field, now.to_s)
93
+ conn.call('HSET', "b-#{bid}", field.to_s.sub('_at', ''), '1')
94
+ end
95
+ end
96
+
97
+ # True once `:death` has fired for this batch — from one of its own
98
+ # jobs dying or from a descendant's death cascading up. Suppresses
99
+ # `:success`, which must never fire after any death in the subtree.
100
+ #
101
+ # Reads the durable `death` field on `b-<bid>` (written by `record_event`),
102
+ # not the `b-<bid>-death` dedup key — the dedup key has its own 30d TTL
103
+ # and can expire while an ancestor batch is still open, after which a
104
+ # late `maybe_fire` would wrongly emit `:success`.
105
+ def death_fired?(bid)
106
+ Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'death') } == '1'
107
+ end
108
+
109
+ # Per-callback rescue: one bad spec or a transient enqueue failure must
110
+ # not strand the batch with the remaining callbacks for this event
111
+ # un-enqueued. Log and move on so every other callback still fires.
112
+ def enqueue_callbacks(bid, event)
113
+ callbacks, queue = callback_specs_for(bid)
114
+
115
+ callbacks.each do |(cb_event, target, options)|
116
+ next unless cb_event == event
117
+
118
+ enqueue_callback_job(bid, target, event, options, queue)
119
+ rescue StandardError => e
120
+ Wurk.logger.warn("batch #{bid}: #{event} callback #{target.inspect} enqueue failed: #{e.class}: #{e.message}")
121
+ end
122
+ end
123
+
124
+ def callback_specs_for(bid)
125
+ raw = Wurk.redis { |conn| conn.call('HMGET', "b-#{bid}", 'callbacks', 'callback_queue') }
126
+ callbacks_json, queue = raw
127
+ queue = 'default' if queue.nil? || queue.empty?
128
+ parsed = parse_callbacks(callbacks_json)
129
+ [parsed, queue]
130
+ end
131
+
132
+ def parse_callbacks(raw)
133
+ return [] if raw.nil? || raw.empty?
134
+
135
+ JSON.parse(raw)
136
+ rescue JSON::ParserError
137
+ []
138
+ end
139
+
140
+ def enqueue_callback_job(bid, target, event, options, queue)
141
+ Wurk::Client.push(
142
+ 'class' => 'Wurk::Batch::CallbackJob',
143
+ 'args' => [bid, target, event, options],
144
+ 'queue' => queue,
145
+ 'retry' => true
146
+ )
147
+ end
148
+
149
+ # When a child batch's `:success` fires, decrement the parent's pkids
150
+ # set so the parent's own `:success` waits on the full subtree. When
151
+ # parent's pkids hits 0 *and* its own pending is 0, parent's success
152
+ # fires too.
153
+ def propagate_to_parent(bid)
154
+ parent_bid = parent_bid_for(bid)
155
+ return if parent_bid.nil? || parent_bid.empty?
156
+ return unless pkids_drained?(parent_bid, bid)
157
+
158
+ maybe_fire(parent_bid, pending: pending_for(parent_bid), live: live_for(parent_bid))
159
+ end
160
+
161
+ def parent_bid_for(bid)
162
+ Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'parent_bid') }
163
+ end
164
+
165
+ def pkids_drained?(parent_bid, child_bid)
166
+ Wurk.redis do |conn|
167
+ conn.call('SREM', "b-#{parent_bid}-pkids", child_bid)
168
+ conn.call('SCARD', "b-#{parent_bid}-pkids").to_i.zero?
169
+ end
170
+ end
171
+
172
+ def pending_for(bid) = Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'pending') }.to_i
173
+ def live_for(bid) = Wurk.redis { |conn| conn.call('SCARD', "b-#{bid}-jids") }.to_i
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../middleware'
4
+
5
+ module Wurk
6
+ class Batch
7
+ # Client middleware. When a `Job.perform_async` runs inside a
8
+ # `batch.jobs { ... }` block, Thread.current[Batch::THREAD_KEY] holds
9
+ # the active batch — we stamp `bid` onto the payload so the worker
10
+ # can re-open the batch and Client#raw_push can route through
11
+ # BATCH_PUSH for atomic increment+LPUSH.
12
+ #
13
+ # Auto-registered at the head of the client chain when this file is
14
+ # required.
15
+ class ClientMiddleware
16
+ include Wurk::Middleware::ClientMiddleware
17
+
18
+ def call(_worker, job, _queue, _redis_pool)
19
+ batch = Thread.current[Batch::THREAD_KEY]
20
+ job['bid'] = batch.bid if batch
21
+ yield
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ Wurk.configuration.client_middleware.prepend(Wurk::Batch::ClientMiddleware)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lua'
4
+ require_relative 'callbacks'
5
+
6
+ module Wurk
7
+ class Batch
8
+ # Registered as a config death_handler. Fires for every job that
9
+ # exhausts retries or carries `dead: false` and discards. If the job
10
+ # carries a `bid`, we BATCH_ACK_COMPLETE → record the death → fire
11
+ # `:death` callback exactly once per batch (first death only).
12
+ #
13
+ # Spec: docs/target/sidekiq-pro.md §2.4 (`:death`).
14
+ class DeathHandler
15
+ def self.call(job, _exception)
16
+ bid = job['bid']
17
+ return unless bid
18
+
19
+ result = Wurk.redis do |conn|
20
+ Wurk::Lua::Loader.eval_cached(
21
+ conn,
22
+ :batch_ack_complete,
23
+ keys: ["b-#{bid}", "b-#{bid}-jids", "b-#{bid}-died", "b-#{bid}-failed"],
24
+ argv: [job['jid']]
25
+ )
26
+ end
27
+ live, _died, first_death = Array(result).map(&:to_i)
28
+
29
+ Wurk::Batch::Callbacks.fire_death(bid) if first_death == 1
30
+ return unless live.zero?
31
+
32
+ Wurk::Batch::Callbacks.fire_complete(bid)
33
+ Wurk::Batch::Callbacks.propagate_to_parent(bid)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ Wurk.configuration.death_handlers << Wurk::Batch::DeathHandler
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../worker'
4
+
5
+ module Wurk
6
+ class Batch
7
+ # No-op marker job inserted into an empty `batch.jobs { }` block so
8
+ # `:complete` and `:success` callbacks still fire. Pro 7.1+ behaviour:
9
+ # without this marker, total=0 means no batch_push ever ran and the
10
+ # callback path can't tell "empty batch" from "never flushed batch".
11
+ #
12
+ # Spec: docs/target/sidekiq-pro.md §2.3 / §12.
13
+ class Empty
14
+ include Wurk::Job
15
+
16
+ sidekiq_options retry: false, queue: 'default'
17
+
18
+ def perform; end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../middleware'
5
+ require_relative '../lua'
6
+
7
+ module Wurk
8
+ class Batch
9
+ # Server middleware. Runs around `perform` for any job carrying a `bid`.
10
+ # On success → BATCH_ACK_SUCCESS → if pending hit zero and no deaths,
11
+ # enqueue `:success` callback jobs; if live jids hit zero, enqueue
12
+ # `:complete` callback jobs.
13
+ #
14
+ # Invalidated batches short-circuit: the job is skipped without
15
+ # raising — counts as a "success" for batch purposes per spec §12.
16
+ #
17
+ # Death handling lives in Wurk::Batch::DeathHandler (registered as a
18
+ # config death_handler) because death is signalled from the retry layer,
19
+ # not from this middleware's rescue path.
20
+ class ServerMiddleware
21
+ include Wurk::Middleware::ServerMiddleware
22
+
23
+ def call(_worker, job, _queue)
24
+ bid = job['bid']
25
+ return yield unless bid
26
+
27
+ if invalidated?(bid)
28
+ ack_success(bid, job['jid'])
29
+ return
30
+ end
31
+
32
+ yield
33
+ ack_success(bid, job['jid'])
34
+ end
35
+
36
+ private
37
+
38
+ def invalidated?(bid)
39
+ redis_pool.with { |conn| conn.call('HGET', "b-#{bid}", 'invalidated') } == '1'
40
+ end
41
+
42
+ def ack_success(bid, jid)
43
+ result = redis_pool.with do |conn|
44
+ Wurk::Lua::Loader.eval_cached(
45
+ conn,
46
+ :batch_ack_success,
47
+ keys: ["b-#{bid}", "b-#{bid}-jids"],
48
+ argv: [jid]
49
+ )
50
+ end
51
+ pending, live = Array(result).map(&:to_i)
52
+ return if pending.negative?
53
+
54
+ Wurk::Batch::Callbacks.maybe_fire(bid, pending: pending, live: live)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ require_relative 'callbacks'
61
+
62
+ Wurk.configuration.server_middleware.add(Wurk::Batch::ServerMiddleware)
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Wurk
6
+ class Batch
7
+ # Snapshot of a batch's state — read-only view backed by HGETALL of
8
+ # `b-<bid>` plus the supporting JIDs/failed/died sets. Mirrors the
9
+ # Sidekiq::Batch::Status surface from docs/target/sidekiq-pro.md §2.5.
10
+ #
11
+ # `#data` returns the JSON-friendly hash served by the polling endpoint.
12
+ # `#join` blocks the current thread until `complete?` — test/util only.
13
+ # `#delete` UNLINKs every key associated with the batch.
14
+ class Status
15
+ JOIN_POLL_INTERVAL = 0.5
16
+
17
+ attr_reader :bid
18
+
19
+ def initialize(bid)
20
+ raise ArgumentError, 'bid required' if bid.nil? || bid.to_s.empty?
21
+
22
+ @bid = bid.to_s
23
+ reload!
24
+ end
25
+
26
+ # False when no `b-<bid>` hash exists — a well-formed bid that was never
27
+ # created (or has expired). Lets callers 404 instead of serving an
28
+ # all-zero phantom batch.
29
+ def exists? = !@data.empty?
30
+
31
+ def total = @data['total'].to_i
32
+ def pending = @data['pending'].to_i
33
+ def failures = @data['failures'].to_i
34
+ def created_at = numeric_or_nil(@data['created_at'])
35
+ def complete_at = numeric_or_nil(@data['complete_at'])
36
+ def success_at = numeric_or_nil(@data['success_at'])
37
+ def death_at = numeric_or_nil(@data['death_at'])
38
+ def description = @data['description']
39
+ def parent_bid = @data['parent_bid']
40
+ def callback_queue = @data['callback_queue']
41
+ def invalidated? = @data['invalidated'] == '1'
42
+
43
+ # `:complete` fires when the live jids set is empty (every job has
44
+ # either succeeded or died). Hash field is set by the server callback;
45
+ # falling back to a recompute keeps Status accurate even if the
46
+ # callback dispatch is in flight.
47
+ def complete?
48
+ return true if @data['complete'] == '1'
49
+
50
+ total.positive? && live_jids_count.zero?
51
+ end
52
+
53
+ def failed_jids
54
+ Wurk.redis { |conn| conn.call('SMEMBERS', "b-#{@bid}-failed") }
55
+ end
56
+
57
+ def dead_jids
58
+ Wurk.redis { |conn| conn.call('SMEMBERS', "b-#{@bid}-died") }
59
+ end
60
+
61
+ def child_count
62
+ Wurk.redis { |conn| conn.call('SCARD', "b-#{@bid}-kids") }.to_i
63
+ end
64
+
65
+ def tags
66
+ raw = @data['tags']
67
+ return [] if raw.nil? || raw.empty?
68
+
69
+ JSON.parse(raw)
70
+ rescue JSON::ParserError
71
+ []
72
+ end
73
+
74
+ # JSON-serializable snapshot used by the polling middleware / web UI.
75
+ # Field names are wire-compat with Sidekiq Pro's BatchStatus.
76
+ def data
77
+ {
78
+ 'bid' => @bid,
79
+ 'total' => total,
80
+ 'pending' => pending,
81
+ 'failures' => failures,
82
+ 'created_at' => created_at,
83
+ 'complete_at' => complete_at,
84
+ 'success_at' => success_at,
85
+ 'death_at' => death_at,
86
+ 'complete' => complete?,
87
+ 'invalidated' => invalidated?,
88
+ 'description' => description,
89
+ 'parent_bid' => parent_bid,
90
+ 'tags' => tags,
91
+ 'failed_jids' => failed_jids,
92
+ 'dead_jids' => dead_jids,
93
+ 'child_count' => child_count
94
+ }
95
+ end
96
+
97
+ # Blocks the current thread until `complete?` is true. Test/util only —
98
+ # polling Redis from a worker thread would defeat the whole point of
99
+ # asynchronous batches.
100
+ def join
101
+ loop do
102
+ reload!
103
+ break if complete?
104
+
105
+ sleep JOIN_POLL_INTERVAL
106
+ end
107
+ end
108
+
109
+ # Nukes every key for this batch. Dangerous if jobs are still in flight
110
+ # — they'll succeed/fail without a batch to ack against, callbacks won't
111
+ # fire, and counts get permanently inconsistent. Caller's problem.
112
+ def delete
113
+ Wurk.redis do |conn|
114
+ conn.call('UNLINK', *Batch.keys_for(@bid))
115
+ conn.call('ZREM', 'batches', @bid)
116
+ conn.call('ZREM', 'dead-batches', @bid)
117
+ end
118
+ nil
119
+ end
120
+
121
+ def reload!
122
+ raw = Wurk.redis { |conn| conn.call('HGETALL', "b-#{@bid}") }
123
+ @data = raw.is_a?(Hash) ? raw : raw.each_slice(2).to_h
124
+ self
125
+ end
126
+
127
+ private
128
+
129
+ def live_jids_count
130
+ Wurk.redis { |conn| conn.call('SCARD', "b-#{@bid}-jids") }.to_i
131
+ end
132
+
133
+ def numeric_or_nil(val)
134
+ return nil if val.nil? || val.to_s.empty?
135
+
136
+ val.to_f
137
+ end
138
+ end
139
+ end
140
+ end