wurk 1.7.6 → 1.8.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 (217) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -5
  3. data/app/controllers/concerns/wurk/sse_streaming.rb +2 -1
  4. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +33 -9
  5. data/app/controllers/wurk/api/pagination.rb +25 -7
  6. data/app/controllers/wurk/api_controller.rb +49 -25
  7. data/app/controllers/wurk/extensions_controller.rb +8 -2
  8. data/app/controllers/wurk/profiles_controller.rb +45 -9
  9. data/config/routes.rb +31 -10
  10. data/lib/generators/wurk/install/templates/wurk.rb +1 -1
  11. data/lib/sidekiq/capsule.rb +4 -0
  12. data/lib/sidekiq/component.rb +4 -0
  13. data/lib/sidekiq/config.rb +4 -0
  14. data/lib/sidekiq/deploy.rb +4 -0
  15. data/lib/sidekiq/embedded.rb +4 -0
  16. data/lib/sidekiq/fetch.rb +4 -0
  17. data/lib/sidekiq/iterable_job.rb +4 -0
  18. data/lib/sidekiq/job/interrupt_handler.rb +4 -0
  19. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +4 -0
  20. data/lib/sidekiq/job/iterable/csv_enumerator.rb +4 -0
  21. data/lib/sidekiq/job/iterable.rb +4 -0
  22. data/lib/sidekiq/job_logger.rb +4 -0
  23. data/lib/sidekiq/job_util.rb +4 -0
  24. data/lib/sidekiq/loader.rb +4 -0
  25. data/lib/sidekiq/logger.rb +4 -0
  26. data/lib/sidekiq/metrics/query.rb +4 -0
  27. data/lib/sidekiq/middleware/current_attributes.rb +7 -0
  28. data/lib/sidekiq/middleware/i18n.rb +6 -0
  29. data/lib/sidekiq/middleware/modules.rb +4 -0
  30. data/lib/sidekiq/pro/web.rb +5 -0
  31. data/lib/sidekiq/profiler.rb +4 -0
  32. data/lib/sidekiq/redis_client_adapter.rb +4 -0
  33. data/lib/sidekiq/test_api.rb +5 -0
  34. data/lib/sidekiq/testing/inline.rb +8 -0
  35. data/lib/sidekiq/transaction_aware_client.rb +4 -0
  36. data/lib/sidekiq/worker_compatibility_alias.rb +4 -0
  37. data/lib/sidekiq-ent/periodic/testing.rb +5 -0
  38. data/lib/sidekiq-ent/web.rb +5 -0
  39. data/lib/sidekiq-ent.rb +7 -0
  40. data/lib/sidekiq-pro.rb +7 -0
  41. data/lib/wurk/api/fast.rb +8 -33
  42. data/lib/wurk/api/idempotency.rb +28 -5
  43. data/lib/wurk/api/swarm.rb +1 -1
  44. data/lib/wurk/batch/callbacks.rb +143 -92
  45. data/lib/wurk/batch/death_handler.rb +19 -8
  46. data/lib/wurk/batch/server_middleware.rb +37 -24
  47. data/lib/wurk/batch/status.rb +49 -15
  48. data/lib/wurk/batch.rb +164 -65
  49. data/lib/wurk/capsule.rb +0 -2
  50. data/lib/wurk/client/buffered.rb +227 -106
  51. data/lib/wurk/client.rb +33 -13
  52. data/lib/wurk/compat.rb +13 -0
  53. data/lib/wurk/configuration.rb +155 -24
  54. data/lib/wurk/cpu_count.rb +97 -0
  55. data/lib/wurk/cron.rb +97 -34
  56. data/lib/wurk/dead_set.rb +6 -7
  57. data/lib/wurk/encryption.rb +46 -13
  58. data/lib/wurk/fetcher/many_queues.rb +65 -0
  59. data/lib/wurk/fetcher/orphan_grace.rb +67 -0
  60. data/lib/wurk/fetcher/private_list_key.rb +76 -0
  61. data/lib/wurk/fetcher/reaper.rb +55 -74
  62. data/lib/wurk/fetcher/reliable.rb +23 -17
  63. data/lib/wurk/fetcher/unit_of_work.rb +19 -1
  64. data/lib/wurk/fetcher/unparseable_keys.rb +56 -0
  65. data/lib/wurk/flow/completion.rb +28 -3
  66. data/lib/wurk/flow/creation.rb +24 -4
  67. data/lib/wurk/flow.rb +24 -2
  68. data/lib/wurk/health.rb +182 -40
  69. data/lib/wurk/heartbeat.rb +37 -14
  70. data/lib/wurk/history.rb +7 -1
  71. data/lib/wurk/import/sidekiq_cron.rb +248 -0
  72. data/lib/wurk/job/options.rb +22 -3
  73. data/lib/wurk/job_logger.rb +13 -3
  74. data/lib/wurk/job_record.rb +68 -19
  75. data/lib/wurk/job_retry.rb +12 -6
  76. data/lib/wurk/job_set.rb +99 -51
  77. data/lib/wurk/job_util.rb +27 -8
  78. data/lib/wurk/launcher.rb +84 -40
  79. data/lib/wurk/leader.rb +53 -28
  80. data/lib/wurk/limiter/base.rb +11 -30
  81. data/lib/wurk/limiter/bucket.rb +14 -5
  82. data/lib/wurk/limiter/server_middleware.rb +2 -1
  83. data/lib/wurk/limiter.rb +1 -1
  84. data/lib/wurk/loader.rb +48 -0
  85. data/lib/wurk/lua/batch_ack_complete.lua +28 -0
  86. data/lib/wurk/lua/batch_ack_failed.lua +18 -0
  87. data/lib/wurk/lua/batch_ack_success.lua +35 -0
  88. data/lib/wurk/lua/batch_append_callback.lua +66 -0
  89. data/lib/wurk/lua/batch_delete.lua +31 -0
  90. data/lib/wurk/lua/batch_hold.lua +17 -0
  91. data/lib/wurk/lua/batch_invalidate.lua +14 -0
  92. data/lib/wurk/lua/batch_kid_done.lua +14 -0
  93. data/lib/wurk/lua/batch_push.lua +47 -0
  94. data/lib/wurk/lua/batch_remove_jobs.lua +29 -0
  95. data/lib/wurk/lua/batch_schedule.lua +27 -0
  96. data/lib/wurk/lua/fetch_first.lua +27 -0
  97. data/lib/wurk/lua/flow_abandon.lua +16 -21
  98. data/lib/wurk/lua/flow_advance.lua +41 -26
  99. data/lib/wurk/lua/flow_create.lua +24 -9
  100. data/lib/wurk/lua/flow_fail.lua +3 -3
  101. data/lib/wurk/lua/leader_campaign.lua +19 -0
  102. data/lib/wurk/lua/limiter_bucket_acquire.lua +3 -2
  103. data/lib/wurk/lua/limiter_list_sweep.lua +8 -9
  104. data/lib/wurk/lua/loader.rb +36 -23
  105. data/lib/wurk/lua/throttle_slot.lua +13 -26
  106. data/lib/wurk/lua.rb +31 -244
  107. data/lib/wurk/manager.rb +25 -18
  108. data/lib/wurk/metrics/accumulator.rb +31 -10
  109. data/lib/wurk/metrics/dashboard_series.rb +163 -0
  110. data/lib/wurk/metrics/histogram.rb +46 -0
  111. data/lib/wurk/metrics/history.rb +49 -57
  112. data/lib/wurk/metrics/prometheus.rb +232 -0
  113. data/lib/wurk/metrics/query.rb +127 -155
  114. data/lib/wurk/metrics/rollup.rb +2 -2
  115. data/lib/wurk/metrics/statsd.rb +1 -1
  116. data/lib/wurk/middleware/interrupt_handler.rb +6 -2
  117. data/lib/wurk/middleware/poison_pill.rb +5 -4
  118. data/lib/wurk/middleware/timeout.rb +8 -8
  119. data/lib/wurk/process_set.rb +5 -5
  120. data/lib/wurk/processor.rb +69 -31
  121. data/lib/wurk/profile_set.rb +19 -24
  122. data/lib/wurk/profiler.rb +48 -50
  123. data/lib/wurk/queue.rb +36 -19
  124. data/lib/wurk/rails_boot.rb +43 -1
  125. data/lib/wurk/railtie.rb +2 -0
  126. data/lib/wurk/rake_tasks.rb +18 -0
  127. data/lib/wurk/redact.rb +39 -0
  128. data/lib/wurk/redis_connection.rb +6 -3
  129. data/lib/wurk/redis_options.rb +21 -4
  130. data/lib/wurk/redis_pool.rb +119 -21
  131. data/lib/wurk/scheduled.rb +44 -11
  132. data/lib/wurk/sentry/error_handler.rb +53 -7
  133. data/lib/wurk/sentry/middleware.rb +7 -8
  134. data/lib/wurk/shutdown_gate.rb +24 -1
  135. data/lib/wurk/sorted_entry.rb +16 -5
  136. data/lib/wurk/stats.rb +9 -4
  137. data/lib/wurk/status.rb +2 -2
  138. data/lib/wurk/swarm/child_boot.rb +48 -38
  139. data/lib/wurk/swarm/liveness.rb +153 -0
  140. data/lib/wurk/swarm/restart.rb +24 -3
  141. data/lib/wurk/swarm.rb +115 -24
  142. data/lib/wurk/telemetry/server_middleware.rb +2 -5
  143. data/lib/wurk/testing.rb +15 -5
  144. data/lib/wurk/throttle.rb +16 -1
  145. data/lib/wurk/topology.rb +1 -5
  146. data/lib/wurk/unique.rb +1 -1
  147. data/lib/wurk/version.rb +1 -1
  148. data/lib/wurk/watchdog.rb +2 -2
  149. data/lib/wurk/web/config.rb +56 -20
  150. data/lib/wurk/web/enterprise.rb +21 -18
  151. data/lib/wurk/web/extension.rb +98 -22
  152. data/lib/wurk/web/rack_app.rb +12 -11
  153. data/lib/wurk/web/search.rb +18 -8
  154. data/lib/wurk/worker/setter.rb +17 -14
  155. data/lib/wurk/worker.rb +6 -67
  156. data/lib/wurk.rb +2 -3
  157. data/vendor/assets/dashboard/assets/ArgsValue-_MYkd_vA.js +1 -0
  158. data/vendor/assets/dashboard/assets/BatchDetail-DPmg-hle.js +1 -0
  159. data/vendor/assets/dashboard/assets/Batches-NTgqSlvD.js +1 -0
  160. data/vendor/assets/dashboard/assets/Busy-2rxcLJMq.js +1 -0
  161. data/vendor/assets/dashboard/assets/Cron-BFpJgKYb.js +1 -0
  162. data/vendor/assets/dashboard/assets/Dashboard-C8VOE2S7.js +1 -0
  163. data/vendor/assets/dashboard/assets/Dead-Dse2YEbZ.js +1 -0
  164. data/vendor/assets/dashboard/assets/Extension-BIc2c1zG.js +1 -0
  165. data/vendor/assets/dashboard/assets/FilterBox-BfZm8ZzH.js +1 -0
  166. data/vendor/assets/dashboard/assets/FlowDetail-BWddOtgf.js +1 -0
  167. data/vendor/assets/dashboard/assets/FlowState-6lGsojxE.js +1 -0
  168. data/vendor/assets/dashboard/assets/Flows-BgKTlYwI.js +1 -0
  169. data/vendor/assets/dashboard/assets/JobDetailModal-BrjmO98c.js +2 -0
  170. data/vendor/assets/dashboard/assets/Limiters-DeUkfbcg.js +1 -0
  171. data/vendor/assets/dashboard/assets/Metrics-DzmAgBFG.js +1 -0
  172. data/vendor/assets/dashboard/assets/NotFound-CQLLXIfa.js +1 -0
  173. data/vendor/assets/dashboard/assets/PageHeader-CeSaqysY.js +1 -0
  174. data/vendor/assets/dashboard/assets/Profiles-CI2i2tl8.js +1 -0
  175. data/vendor/assets/dashboard/assets/Queues-DUCQ_3St.js +1 -0
  176. data/vendor/assets/dashboard/assets/Retries-Dc2RFuTf.js +1 -0
  177. data/vendor/assets/dashboard/assets/Scheduled-B8zOKCLC.js +1 -0
  178. data/vendor/assets/dashboard/assets/Search-x0eS-V-U.js +1 -0
  179. data/vendor/assets/dashboard/assets/charts-VggSMFGc.js +1 -0
  180. data/vendor/assets/dashboard/assets/i18n-B6AbnmJn.js +1 -0
  181. data/vendor/assets/dashboard/assets/index-CTWV8VL4.js +141 -0
  182. data/vendor/assets/dashboard/assets/index-WpZjU076.css +1 -0
  183. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-BsYWeStI.js +1 -0
  184. data/vendor/assets/dashboard/assets/useSort-BWWfpnZH.js +1 -0
  185. data/vendor/assets/dashboard/assets/utils-DnzJXljj.js +1 -0
  186. data/vendor/assets/dashboard/index.html +3 -3
  187. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  188. metadata +90 -34
  189. data/config/locales/en.yml +0 -15
  190. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +0 -1
  191. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +0 -1
  192. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +0 -1
  193. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +0 -1
  194. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +0 -1
  195. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +0 -1
  196. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +0 -1
  197. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +0 -1
  198. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +0 -1
  199. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +0 -1
  200. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +0 -1
  201. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +0 -1
  202. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +0 -2
  203. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +0 -1
  204. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +0 -1
  205. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +0 -1
  206. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +0 -1
  207. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +0 -1
  208. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +0 -1
  209. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +0 -1
  210. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +0 -1
  211. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +0 -1
  212. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +0 -1
  213. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +0 -141
  214. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +0 -1
  215. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +0 -1
  216. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +0 -1
  217. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +0 -1
@@ -1,45 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative '../lua'
4
5
 
5
6
  module Wurk
6
7
  class Batch
8
+ # Raised by `Callbacks.enqueue_callbacks` once every callback for the event
9
+ # has been attempted and at least one could not be enqueued, so the caller
10
+ # leaves the event's dedup marker unwritten and the next fire retries it.
11
+ class CallbackEnqueueError < StandardError; end
12
+
7
13
  # 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.
14
+ # them as ordinary jobs on the batch's `callback_queue`.
11
15
  #
12
16
  # Callback wrapper job: Wurk::Batch::CallbackJob — given a target spec
13
17
  # ("Klass" or "Klass#method") and options hash, it instantiates and
14
18
  # invokes on_<event> (or the named method) with the Status snapshot.
19
+ #
20
+ # A child batch's `:complete` and `:success` callback jobs are enqueued
21
+ # *into its parent batch* (their payload carries the parent's `bid`), so
22
+ # they are live jids of the parent: the parent cannot drain until they
23
+ # have run. That is what makes the spec §2.9 step workflow work —
24
+ # `step1_done` reopens the parent and adds `step2` before the parent can
25
+ # fire. A callback job that fails counts against the parent like any of
26
+ # its jobs (a death suppresses the parent's `:success`). `:death`
27
+ # callbacks stay outside: they fire while the child is still running, and
28
+ # a notification must not hold or poison the parent.
15
29
  module Callbacks
16
30
  module_function
17
31
 
18
- # Called from the server middleware after BATCH_ACK_SUCCESS (and from
19
- # DeathHandler when a death drains the last live jid). Fires `:complete`
20
- # when live jids hit 0; fires `:success` when pending also hits 0 and
21
- # there have been no deaths.
32
+ # In-process re-drive for the fire and ack paths. Nothing else re-drives
33
+ # a fire once the job that drained the batch has acked, so a transient
34
+ # Redis error there would otherwise strand the callbacks for good.
35
+ REDRIVE_ATTEMPTS = 3
36
+ REDRIVE_BACKOFF = 0.05
37
+
38
+ # Runs on the post-state of a batch script that moved a member out of the
39
+ # live jids or pending-children set (see the gate note heading
40
+ # lua/batch_ack_success.lua). Fires `:complete` once both sets are empty, and
41
+ # `:success` when pending is also 0 and nothing in the subtree is dead;
42
+ # then hands the drain to the parent.
22
43
  #
23
- # Both fires are additionally gated on `b-<bid>-pkids` being empty —
24
- # children whose own subtree hasn't finished yet (#209). Spec §2.4:
25
- # child `:complete`/`:success` fire before the parent's, so when the
26
- # parent's *own* last job acks while a child batch is still running,
27
- # nothing fires here; the last child's propagate_to_parent re-invokes
28
- # this and fires then. The SREM in pkids_drained? happens before that
29
- # re-invocation, so at most one of the racing paths reaches a fire and
30
- # the callback markers absorb the rest (see `fire_complete` for the one
31
- # window that can still duplicate).
32
- def maybe_fire(bid, pending:, live:)
33
- return unless live.zero?
34
- return unless kids_finished?(bid)
44
+ # Spec §2.4: child `:complete`/`:success` fire before the parent's, so a
45
+ # parent whose own jobs are done while a child batch is still running
46
+ # waits here (kids > 0); the child's `propagate_to_parent` re-enters with
47
+ # the parent's fresh state when it finishes.
48
+ def maybe_fire(bid, pending:, live:, kids:)
49
+ return unless live.zero? && kids.zero?
35
50
 
36
51
  fire_complete(bid)
37
52
  fire_success(bid) if pending.zero? && !subtree_dead?(bid)
38
53
  propagate_to_parent(bid)
39
54
  end
40
55
 
41
- def kids_finished?(bid)
42
- Wurk.redis { |conn| conn.call('SCARD', "b-#{bid}-pkids") }.to_i.zero?
56
+ # Yields the attempt number (0-based) and retries a StandardError up to
57
+ # REDRIVE_ATTEMPTS times in total, re-raising the last one. Every block
58
+ # handed to it is idempotent: the ack scripts are SREM/SADD-guarded and
59
+ # the fires are marker-guarded.
60
+ def retrying(attempts: REDRIVE_ATTEMPTS)
61
+ attempt = 0
62
+ begin
63
+ yield attempt
64
+ rescue StandardError
65
+ attempt += 1
66
+ raise if attempt >= attempts
67
+
68
+ sleep(REDRIVE_BACKOFF * attempt)
69
+ retry
70
+ end
43
71
  end
44
72
 
45
73
  # Fired from Wurk::Batch::DeathHandler whenever a death makes the died
@@ -58,13 +86,20 @@ module Wurk
58
86
  # dashboard and `subtree_dead?` all still see a dead batch. `:complete`
59
87
  # and `:success` have no such fallback — the callback is their whole
60
88
  # signal — and this claim additionally gates `cascade_death`, which
61
- # would otherwise re-walk the ancestor chain on every re-invocation.
89
+ # would otherwise re-walk the ancestor chain on every re-invocation. An
90
+ # enqueue that *raises* is different from a crash: the claim is handed
91
+ # back so the caller's re-drive fires `:death` again.
62
92
  def fire_death(bid)
63
93
  record_event(bid, 'death_at')
64
94
  index_dead(bid)
65
95
  return unless dedup_set(bid, 'death')
66
96
 
67
- enqueue_callbacks(bid, 'death')
97
+ begin
98
+ enqueue_callbacks(bid, 'death')
99
+ rescue CallbackEnqueueError
100
+ Wurk.redis { |conn| conn.call('DEL', "b-#{bid}-death") }
101
+ raise
102
+ end
68
103
  cascade_death(bid)
69
104
  end
70
105
 
@@ -95,22 +130,19 @@ module Wurk
95
130
  end
96
131
 
97
132
  # `:complete` and `:success` mark their dedup key *after* the enqueue,
98
- # never before (F16). Nothing re-drives a fire once the acking job's
99
- # BATCH_ACK_SUCCESS has SREM'd its jid — that job's retry gets
100
- # `pending == -1` and returns before maybe_fire — so a claim-then-enqueue
101
- # ordering turns a crash in between into callbacks that are never
102
- # enqueued by anyone, ever. Enqueuing first makes the durable side effect
103
- # happen before the marker that suppresses it.
133
+ # never before (F16): a claim-then-enqueue ordering turns a crash in
134
+ # between into callbacks nobody ever enqueues. Enqueuing first makes the
135
+ # durable side effect happen before the marker that suppresses it, and an
136
+ # enqueue that raised leaves the marker unwritten so the re-drive (the
137
+ # caller's `retrying`, or a reclaimed re-run of the draining job) fires
138
+ # the event again.
104
139
  #
105
140
  # The accepted direction is a duplicate over a lost callback: callback
106
141
  # jobs retry like any other job and must already be idempotent (spec
107
142
  # §2.4, §12 "Callback retries"), so firing one twice is a cost the app
108
143
  # is required to absorb, while losing one silently strands the batch.
109
- #
110
- # `dedup_marked?` still collapses every *sequential* re-invocation — a
111
- # reclaimed child re-running propagate_to_parent, a second DeathHandler
112
- # pass — so the duplicate window is only two genuinely concurrent acks
113
- # interleaving between each other's check and mark.
144
+ # Concurrent duplicates are confined to re-drives: the fire gate admits
145
+ # exactly one caller per drain transition.
114
146
  #
115
147
  # `record_event` stays ahead of the enqueue: the callback job reads a
116
148
  # Status snapshot and must see `complete_at`/`success_at` already set.
@@ -140,11 +172,8 @@ module Wurk
140
172
  # full success. `created_at` shares the CLOCK_REALTIME epoch we record it
141
173
  # with. No-op without a dogstatsd client.
142
174
  #
143
- # Strictly best-effort: this runs on the acking job's thread ahead of the
144
- # enqueue, and that ack already removed the jid, so a raise here (e.g. a
145
- # Redis hiccup on the HGET) would abort `fire_success` with nothing left
146
- # to re-drive it — the success callbacks and linger would be stranded for
147
- # good. Swallow and log instead.
175
+ # Strictly best-effort: a raise here would abort `fire_success` ahead of
176
+ # the enqueue for the sake of a metric. Swallow and log instead.
148
177
  def emit_duration_metric(bid)
149
178
  created = Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'created_at') }
150
179
  return if created.nil? || created.to_s.empty?
@@ -163,14 +192,13 @@ module Wurk
163
192
  raw = Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'linger') }
164
193
  seconds = raw.nil? || raw.to_s.empty? ? Batch::POST_SUCCESS_EXPIRY_SECONDS : raw.to_i
165
194
  Wurk.redis do |conn|
166
- Batch.keys_for(bid).each { |key| conn.call('EXPIRE', key, seconds) }
195
+ conn.pipelined { |pipe| Batch.keys_for(bid).each { |key| pipe.call('EXPIRE', key, seconds) } }
167
196
  end
168
197
  end
169
198
 
170
199
  # True once `b-<bid>-<event>` exists, i.e. an enqueue pass for `event`
171
200
  # has completed. The read-side half of the enqueue-then-mark ordering in
172
- # `fire_complete`/`fire_success`; `fire_death` needs no equivalent
173
- # because its `dedup_set` still doubles as the claim.
201
+ # `fire_complete`/`fire_success`.
174
202
  def dedup_marked?(bid, event)
175
203
  Wurk.redis { |conn| conn.call('EXISTS', "b-#{bid}-#{event}") }.to_i == 1
176
204
  end
@@ -190,17 +218,17 @@ module Wurk
190
218
  end
191
219
  end
192
220
 
193
- # The HSETs resurrect the hash when a callback fires for a batch whose keys
194
- # already expired (a child batch outliving its parent's 30d window), so the
195
- # write is followed by an NX stamp — without it the resurrected hash would
196
- # have no clock at all. NX leaves a live batch's expiry, and the shorter
197
- # post-success `linger` window, untouched.
221
+ # The HSET resurrects the hash when a callback fires for a batch whose keys
222
+ # already expired, so the write is followed by an NX stamp — without it the
223
+ # resurrected hash would have no clock at all. NX leaves a live batch's
224
+ # expiry, and the shorter post-success `linger` window, untouched.
198
225
  def record_event(bid, field)
199
226
  now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
200
227
  Wurk.redis do |conn|
201
- conn.call('HSET', "b-#{bid}", field, now.to_s)
202
- conn.call('HSET', "b-#{bid}", field.to_s.sub('_at', ''), '1')
203
- conn.call('EXPIRE', "b-#{bid}", Batch::DEFAULT_EXPIRY_SECONDS, 'NX')
228
+ conn.pipelined do |pipe|
229
+ pipe.call('HSET', "b-#{bid}", field, now.to_s, field.delete_suffix('_at'), '1')
230
+ pipe.call('EXPIRE', "b-#{bid}", Batch::DEFAULT_EXPIRY_SECONDS, 'NX')
231
+ end
204
232
  end
205
233
  end
206
234
 
@@ -233,13 +261,13 @@ module Wurk
233
261
  # last dead job is manually retried back to success, the descendant
234
262
  # clears its OWN death mark (#212, in BATCH_PUSH) — but every ancestor
235
263
  # was marked by the death *cascade*, not by a jid in its own died set,
236
- # so nothing here ever cleared them and the ancestor's `:success`
237
- # stayed suppressed forever. Re-evaluate this batch: drop its durable
238
- # death mark and `dead-batches` membership once its own died set is
239
- # empty AND no child still carries a death mark. The `b-<bid>-death`
240
- # notify dedup key is deliberately left intact, so a later re-death
241
- # re-marks the batch (fire_death restores the flag before its own
242
- # dedup guard) without ever re-enqueuing `:death`.
264
+ # so nothing there ever clears them. Re-evaluated whenever a child
265
+ # drains (propagate_to_parent): drop the durable death mark and
266
+ # `dead-batches` membership once the batch's own died set is empty AND
267
+ # no child still carries a death mark. The `b-<bid>-death` notify dedup
268
+ # key is deliberately left intact, so a later re-death re-marks the batch
269
+ # (fire_death restores the flag before its own dedup guard) without ever
270
+ # re-enqueuing `:death`.
243
271
  def clear_death_on_recovery(bid)
244
272
  return unless death_fired?(bid)
245
273
  return if own_died_remaining?(bid)
@@ -261,26 +289,46 @@ module Wurk
261
289
  end
262
290
 
263
291
  # Per-callback rescue: one bad spec or a transient enqueue failure must
264
- # not strand the batch with the remaining callbacks for this event
265
- # un-enqueued. Log and move on so every other callback still fires.
292
+ # not keep the remaining callbacks for this event from being attempted.
293
+ # Each failure reaches the error handlers; once all were attempted, any
294
+ # failure raises so the caller does not write the event's marker.
266
295
  def enqueue_callbacks(bid, event)
267
- callbacks, queue = callback_specs_for(bid)
296
+ failed = callback_items(bid, event).count { |item| !push_callback(bid, event, item) }
297
+ return if failed.zero?
298
+
299
+ raise CallbackEnqueueError, "batch #{bid}: #{failed} #{event} callback(s) failed to enqueue"
300
+ end
268
301
 
269
- callbacks.each do |(cb_event, target, options)|
302
+ def callback_items(bid, event)
303
+ callbacks, queue, parent_bid = callback_specs_for(bid)
304
+ parent_bid = nil if event == 'death'
305
+ callbacks.filter_map do |(cb_event, target, options)|
270
306
  next unless cb_event == event
271
307
 
272
- enqueue_callback_job(bid, target, event, options, queue)
273
- rescue StandardError => e
274
- Wurk.logger.warn("batch #{bid}: #{event} callback #{target.inspect} enqueue failed: #{e.class}: #{e.message}")
308
+ item = callback_item(bid, target, event, options, queue)
309
+ item['bid'] = parent_bid if parent_bid
310
+ item
275
311
  end
276
312
  end
277
313
 
314
+ # True when the callback job was enqueued.
315
+ def push_callback(bid, event, item)
316
+ Batch.with_thread_batch(nil, nil) { Wurk::Client.push(item) }
317
+ true
318
+ rescue StandardError => e
319
+ Wurk.configuration.handle_exception(
320
+ e, { context: "batch #{bid}: #{event} callback #{item['args'][1].inspect} enqueue failed", bid: bid }
321
+ )
322
+ false
323
+ end
324
+
278
325
  def callback_specs_for(bid)
279
- raw = Wurk.redis { |conn| conn.call('HMGET', "b-#{bid}", 'callbacks', 'callback_queue') }
280
- callbacks_json, queue = raw
326
+ callbacks_json, queue, parent_bid = Wurk.redis do |conn|
327
+ conn.call('HMGET', "b-#{bid}", 'callbacks', 'callback_queue', 'parent_bid')
328
+ end
281
329
  queue = 'default' if queue.nil? || queue.empty?
282
- parsed = parse_callbacks(callbacks_json)
283
- [parsed, queue]
330
+ parent_bid = nil if parent_bid.nil? || parent_bid.empty?
331
+ [parse_callbacks(callbacks_json), queue, parent_bid]
284
332
  end
285
333
 
286
334
  def parse_callbacks(raw)
@@ -291,46 +339,49 @@ module Wurk
291
339
  []
292
340
  end
293
341
 
294
- def enqueue_callback_job(bid, target, event, options, queue)
295
- Wurk::Client.push(
342
+ # Pushed (by push_callback) with the batch thread-locals cleared: a
343
+ # fire can run on a thread that is inside some other batch's `#jobs`
344
+ # block (a hold released at the end of a nested block), and the client
345
+ # middleware would otherwise stamp that batch's bid over the parent bid
346
+ # — or the buffer would swallow the push until that block exits.
347
+ def callback_item(bid, target, event, options, queue)
348
+ {
296
349
  'class' => 'Wurk::Batch::CallbackJob',
297
350
  'args' => [bid, target, event, options],
298
351
  'queue' => queue,
299
352
  'retry' => true
300
- )
353
+ }
301
354
  end
302
355
 
303
- # When a child batch finishes (its live jids hit 0 — by success or
304
- # death), remove it from the parent's pkids set so the parent's own
305
- # callbacks wait on the full subtree. When the parent's pkids hits 0,
306
- # re-run the parent's maybe_fire: if its own counts are already at
307
- # zero (the parent-acks-first race), this is what finally fires it.
356
+ # When a child batch drains (by success or death), drop it from the
357
+ # parent's pending children and re-evaluate the parent with the state
358
+ # BATCH_KID_DONE read atomically alongside the SREM. A re-run of the
359
+ # child (a reclaimed job, a re-drive) removes nothing but still reports
360
+ # the parent's state, so a parent fire lost to a crash is re-driven too.
361
+ #
362
+ # A recovered child may have lifted the last death from the parent's
363
+ # subtree — clear the parent's cascaded mark first, so the parent's
364
+ # `:success` can fire whenever it next drains (here, or when this
365
+ # child's callback jobs ack into it). Harmless on the death path: the
366
+ # dying child still carries its mark, so any_child_dead? keeps the
367
+ # parent dead.
308
368
  def propagate_to_parent(bid)
309
369
  parent_bid = parent_bid_for(bid)
310
370
  return if parent_bid.nil? || parent_bid.empty?
311
- return unless pkids_drained?(parent_bid, bid)
312
371
 
313
- # A recovered child may have lifted the last death from the parent's
314
- # subtree — clear the parent's cascaded mark before its gate runs, so
315
- # `:success` can fire. Harmless on the death path: the dying child
316
- # still carries its mark, so any_child_dead? keeps the parent dead.
317
372
  clear_death_on_recovery(parent_bid)
318
- maybe_fire(parent_bid, pending: pending_for(parent_bid), live: live_for(parent_bid))
373
+ pending, live, kids = Wurk.redis do |conn|
374
+ Wurk::Lua::Loader.eval_cached(
375
+ conn, :batch_kid_done,
376
+ keys: ["b-#{parent_bid}", "b-#{parent_bid}-jids", "b-#{parent_bid}-pkids"], argv: [bid]
377
+ )
378
+ end
379
+ maybe_fire(parent_bid, pending: pending.to_i, live: live.to_i, kids: kids.to_i)
319
380
  end
320
381
 
321
382
  def parent_bid_for(bid)
322
383
  Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'parent_bid') }
323
384
  end
324
-
325
- def pkids_drained?(parent_bid, child_bid)
326
- Wurk.redis do |conn|
327
- conn.call('SREM', "b-#{parent_bid}-pkids", child_bid)
328
- conn.call('SCARD', "b-#{parent_bid}-pkids").to_i.zero?
329
- end
330
- end
331
-
332
- def pending_for(bid) = Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'pending') }.to_i
333
- def live_for(bid) = Wurk.redis { |conn| conn.call('SCARD', "b-#{bid}-jids") }.to_i
334
385
  end
335
386
  end
336
387
  end
@@ -10,32 +10,43 @@ module Wurk
10
10
  # carries a `bid`, we BATCH_ACK_COMPLETE → record the death → fire
11
11
  # `:death` callback exactly once per batch (first death only).
12
12
  #
13
+ # The retry layer runs this after the job is already in the morgue and
14
+ # acked off its private list, and swallows whatever it raises — nothing
15
+ # would ever deliver this death to the batch again. So the whole pass is
16
+ # re-driven in-process (`Callbacks.retrying`): the ack script is
17
+ # SREM/SADD-guarded and the fires are marker-guarded, so a replay is safe.
18
+ # A replay can't trust `first_death` (the lost attempt may already have
19
+ # moved the jid into the died set), so it falls back to the `:death`
20
+ # claim marker to decide whether `:death` still has to fire.
21
+ #
13
22
  # Spec: docs/target/sidekiq-pro.md §2.4 (`:death`).
14
23
  class DeathHandler
15
24
  def self.call(job, _exception)
16
25
  bid = job['bid']
17
26
  return unless bid
18
27
 
19
- result = Wurk.redis do |conn|
28
+ Callbacks.retrying { |attempt| record(bid, job['jid'], replay: attempt.positive?) }
29
+ end
30
+
31
+ def self.record(bid, jid, replay:)
32
+ live, _died, first_death, kids, pending = Wurk.redis do |conn|
20
33
  Wurk::Lua::Loader.eval_cached(
21
34
  conn,
22
35
  :batch_ack_complete,
23
- keys: ["b-#{bid}", "b-#{bid}-jids", "b-#{bid}-died", "b-#{bid}-failed"],
24
- argv: [job['jid'], Batch::DEFAULT_EXPIRY_SECONDS]
36
+ keys: ["b-#{bid}", "b-#{bid}-jids", "b-#{bid}-died", "b-#{bid}-failed", "b-#{bid}-pkids"],
37
+ argv: [jid, Batch::DEFAULT_EXPIRY_SECONDS]
25
38
  )
26
- end
27
- live, _died, first_death = Array(result).map(&:to_i)
39
+ end.map(&:to_i)
28
40
 
29
41
  restamp_ttls(bid)
30
42
 
31
- Wurk::Batch::Callbacks.fire_death(bid) if first_death == 1
32
- return unless live.zero?
43
+ Callbacks.fire_death(bid) if first_death == 1 || (replay && !Callbacks.dedup_marked?(bid, 'death'))
33
44
 
34
45
  # Through the gated maybe_fire, not a direct fire_complete: this batch
35
46
  # may still have running child batches, and spec §2.4 ordering says
36
47
  # its `:complete` must wait for theirs (#209). `:success` stays
37
48
  # suppressed regardless — the death above set the durable death flag.
38
- Wurk::Batch::Callbacks.maybe_fire(bid, pending: Wurk::Batch::Callbacks.pending_for(bid), live: 0)
49
+ Callbacks.maybe_fire(bid, pending: pending, live: live, kids: kids)
39
50
  end
40
51
 
41
52
  # BATCH_ACK_COMPLETE stamps the two keys it can itself resurrect; this
@@ -1,17 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
3
  require_relative '../middleware'
5
4
  require_relative '../lua'
6
5
  require_relative '../job'
7
6
  require_relative '../job_retry'
7
+ require_relative 'callbacks'
8
8
 
9
9
  module Wurk
10
10
  class Batch
11
11
  # Server middleware. Runs around `perform` for any job carrying a `bid`.
12
- # On success → BATCH_ACK_SUCCESS → if pending hit zero and no deaths,
13
- # enqueue `:success` callback jobs; if live jids hit zero, enqueue
14
- # `:complete` callback jobs.
12
+ # On success → BATCH_ACK_SUCCESS → when that drains the batch, fire its
13
+ # callbacks (Callbacks.maybe_fire).
15
14
  #
16
15
  # On a job raising (and thus heading to retry), records a transient
17
16
  # failure → BATCH_ACK_FAILED → the jid joins `b-<bid>-failed` and
@@ -21,8 +20,18 @@ module Wurk
21
20
  # Limiter::Rescheduled re-enqueue — and cooperative IterableJob
22
21
  # interruption) are re-raised as neither success nor failure.
23
22
  #
23
+ # The success ack runs outside the job's rescue: once `perform` returned,
24
+ # nothing the batch bookkeeping raises may be mistaken for the job failing,
25
+ # or a job that already did its work is retried and runs twice. The ack
26
+ # itself is retried in-process and only raises (sending the job to retry,
27
+ # at-least-once) when Redis stays unreachable — a jid left in the live set
28
+ # would hold the batch open forever. The fire after it never raises: it is
29
+ # re-driven in-process and then reported to the error handlers.
30
+ #
24
31
  # Invalidated batches short-circuit: the job is skipped without
25
32
  # raising — counts as a "success" for batch purposes per spec §12.
33
+ # A callback job is never skipped: it rides in its *parent* batch (see
34
+ # Callbacks), and a cancelled parent must not swallow a child's callback.
26
35
  #
27
36
  # Death handling lives in Wurk::Batch::DeathHandler (registered as a
28
37
  # config death_handler) because death is signalled from the retry layer,
@@ -30,16 +39,15 @@ module Wurk
30
39
  class ServerMiddleware
31
40
  include Wurk::Middleware::ServerMiddleware
32
41
 
42
+ CALLBACK_JOB = 'Wurk::Batch::CallbackJob'
43
+
33
44
  def call(_worker, job, _queue, &)
34
45
  bid = job['bid']
35
46
  return yield unless bid
36
47
 
37
- if invalidated?(bid)
38
- ack_success(bid, job['jid'])
39
- return
40
- end
41
-
42
- run_and_ack(bid, job['jid'], &)
48
+ jid = job['jid']
49
+ run(bid, jid, &) if job['class'] == CALLBACK_JOB || !invalidated?(bid)
50
+ ack_success(bid, jid)
43
51
  end
44
52
 
45
53
  private
@@ -50,9 +58,8 @@ module Wurk
50
58
  # failure: re-raise it untouched, acking neither. Any other exception
51
59
  # means the job failed and will retry (or eventually die): record it
52
60
  # before re-raising.
53
- def run_and_ack(bid, jid)
61
+ def run(bid, jid)
54
62
  yield
55
- ack_success(bid, jid)
56
63
  rescue Wurk::JobRetry::Handled, Wurk::Job::Interrupted
57
64
  raise
58
65
  rescue StandardError
@@ -64,19 +71,27 @@ module Wurk
64
71
  redis_pool.with { |conn| conn.call('HGET', "b-#{bid}", 'invalidated') } == '1'
65
72
  end
66
73
 
74
+ # A jid that was no longer live (`removed == 0`) is a re-run — most
75
+ # often a job reclaimed after a SIGKILL that landed between this ack and
76
+ # the fire. It still goes through the gate with the batch's current
77
+ # state: if the batch is drained, the dead run's fire may never have
78
+ # happened, and the callback markers absorb it if it did.
67
79
  def ack_success(bid, jid)
68
- result = redis_pool.with do |conn|
69
- Wurk::Lua::Loader.eval_cached(
70
- conn,
71
- :batch_ack_success,
72
- keys: ["b-#{bid}", "b-#{bid}-jids", "b-#{bid}-failed"],
73
- argv: [jid]
74
- )
80
+ _removed, pending, live, kids = Callbacks.retrying do
81
+ redis_pool.with { |conn| Batch.ack_success(conn, bid, jid) }
75
82
  end
76
- pending, live = Array(result).map(&:to_i)
77
- return if pending.negative?
83
+ fire(bid, pending, live, kids)
84
+ end
78
85
 
79
- Wurk::Batch::Callbacks.maybe_fire(bid, pending: pending, live: live)
86
+ def fire(bid, pending, live, kids)
87
+ Callbacks.retrying { Callbacks.maybe_fire(bid, pending: pending, live: live, kids: kids) }
88
+ rescue StandardError => e
89
+ error_config.handle_exception(e, { context: "batch #{bid}: firing callbacks after ack", bid: bid })
90
+ end
91
+
92
+ # `config` is a Configuration or a Capsule; only the former reports.
93
+ def error_config
94
+ config.respond_to?(:handle_exception) ? config : config.config
80
95
  end
81
96
 
82
97
  def ack_failed(bid, jid)
@@ -93,6 +108,4 @@ module Wurk
93
108
  end
94
109
  end
95
110
 
96
- require_relative 'callbacks'
97
-
98
111
  Wurk.configuration.server_middleware.add(Wurk::Batch::ServerMiddleware)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require_relative '../lua'
5
+ require_relative 'callbacks'
4
6
 
5
7
  module Wurk
6
8
  class Batch
@@ -10,7 +12,7 @@ module Wurk
10
12
  #
11
13
  # `#data` returns the JSON-friendly hash served by the polling endpoint.
12
14
  # `#join` blocks the current thread until `complete?` — test/util only.
13
- # `#delete` UNLINKs every key associated with the batch.
15
+ # `#delete` removes the batch and every reference to it.
14
16
  class Status
15
17
  JOIN_POLL_INTERVAL = 0.5
16
18
 
@@ -47,7 +49,7 @@ module Wurk
47
49
  def complete?
48
50
  return true if @data['complete'] == '1'
49
51
 
50
- total.positive? && live_jids_count.zero?
52
+ complete_with?(live_jids_count)
51
53
  end
52
54
 
53
55
  def failed_jids
@@ -81,8 +83,10 @@ module Wurk
81
83
  end
82
84
 
83
85
  # JSON-serializable snapshot used by the polling middleware / web UI.
84
- # Field names are wire-compat with Sidekiq Pro's BatchStatus.
86
+ # Field names are wire-compat with Sidekiq Pro's BatchStatus. The four
87
+ # set reads it needs ride one pipeline.
85
88
  def data
89
+ failed, dead, kids, live = set_snapshot
86
90
  {
87
91
  'bid' => @bid,
88
92
  'total' => total,
@@ -92,14 +96,14 @@ module Wurk
92
96
  'complete_at' => complete_at,
93
97
  'success_at' => success_at,
94
98
  'death_at' => death_at,
95
- 'complete' => complete?,
99
+ 'complete' => complete_with?(live.to_i),
96
100
  'invalidated' => invalidated?,
97
101
  'description' => description,
98
102
  'parent_bid' => parent_bid,
99
103
  'tags' => tags,
100
- 'failed_jids' => failed_jids,
101
- 'dead_jids' => dead_jids,
102
- 'child_count' => child_count
104
+ 'failed_jids' => failed,
105
+ 'dead_jids' => dead,
106
+ 'child_count' => kids.to_i
103
107
  }
104
108
  end
105
109
 
@@ -115,15 +119,22 @@ module Wurk
115
119
  end
116
120
  end
117
121
 
118
- # Nukes every key for this batch. Dangerous if jobs are still in flight
119
- # — they'll succeed/fail without a batch to ack against, callbacks won't
120
- # fire, and counts get permanently inconsistent. Caller's problem.
122
+ # Nukes every key for this batch, its index entries and tag indexes,
123
+ # and its membership in the parent's `-kids`/`-pkids` — one atomic
124
+ # script (BATCH_DELETE). Dangerous if jobs are still in flight: they'll
125
+ # succeed/fail without a batch to ack against and this batch's callbacks
126
+ # won't fire. The *parent* is not left waiting on a child that no longer
127
+ # exists: when this was the last thing it waited on, its callbacks fire
128
+ # here.
121
129
  def delete
122
- Wurk.redis do |conn|
123
- conn.call('UNLINK', *Batch.keys_for(@bid))
124
- conn.call('ZREM', 'batches', @bid)
125
- conn.call('ZREM', 'dead-batches', @bid)
126
- end
130
+ reload!
131
+ parent = parent_bid.to_s
132
+ own = Batch.keys_for(@bid)
133
+ pending, live, kids = Wurk.redis do |conn|
134
+ Wurk::Lua::Loader.eval_cached(conn, :batch_delete, keys: delete_keys(own, parent),
135
+ argv: [@bid, own.size, parent.empty? ? '0' : '1'])
136
+ end.map(&:to_i)
137
+ Callbacks.maybe_fire(parent, pending: pending, live: live, kids: kids) unless parent.empty?
127
138
  nil
128
139
  end
129
140
 
@@ -135,6 +146,29 @@ module Wurk
135
146
 
136
147
  private
137
148
 
149
+ # [failed jids, dead jids, child count, live count] in one round trip.
150
+ def set_snapshot
151
+ Wurk.redis do |conn|
152
+ conn.pipelined do |pipe|
153
+ pipe.call('SMEMBERS', "b-#{@bid}-failed")
154
+ pipe.call('SMEMBERS', "b-#{@bid}-died")
155
+ pipe.call('SCARD', "b-#{@bid}-kids")
156
+ pipe.call('SCARD', "b-#{@bid}-jids")
157
+ end
158
+ end
159
+ end
160
+
161
+ # KEYS layout BATCH_DELETE expects.
162
+ def delete_keys(own, parent)
163
+ keys = [*own, 'batches', 'dead-batches']
164
+ keys.push("b-#{parent}", "b-#{parent}-jids", "b-#{parent}-pkids", "b-#{parent}-kids") unless parent.empty?
165
+ keys.concat(tags.map { |t| "tags:#{t}" })
166
+ end
167
+
168
+ def complete_with?(live)
169
+ @data['complete'] == '1' || (total.positive? && live.zero?)
170
+ end
171
+
138
172
  def live_jids_count
139
173
  Wurk.redis { |conn| conn.call('SCARD', "b-#{@bid}-jids") }.to_i
140
174
  end