wurk 1.7.6 → 1.8.0

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 +7 -4
  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 +33 -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-DsPb_KZ-.js +1 -0
  158. data/vendor/assets/dashboard/assets/BatchDetail-BCIXJOpZ.js +1 -0
  159. data/vendor/assets/dashboard/assets/Batches-IjaQfXW6.js +1 -0
  160. data/vendor/assets/dashboard/assets/Busy-CaQrf1Ve.js +1 -0
  161. data/vendor/assets/dashboard/assets/Cron-B18EHn_Y.js +1 -0
  162. data/vendor/assets/dashboard/assets/Dashboard-DdT5ZaUg.js +1 -0
  163. data/vendor/assets/dashboard/assets/Dead-DWIy-Mu8.js +1 -0
  164. data/vendor/assets/dashboard/assets/Extension-BjBUWhHf.js +1 -0
  165. data/vendor/assets/dashboard/assets/FilterBox-DwH3PXXt.js +1 -0
  166. data/vendor/assets/dashboard/assets/FlowDetail-DnexL72n.js +1 -0
  167. data/vendor/assets/dashboard/assets/FlowState-Cr4aX6-d.js +1 -0
  168. data/vendor/assets/dashboard/assets/Flows-DO6EimbZ.js +1 -0
  169. data/vendor/assets/dashboard/assets/JobDetailModal-aPjgq-U9.js +2 -0
  170. data/vendor/assets/dashboard/assets/Limiters-DxpwWCNe.js +1 -0
  171. data/vendor/assets/dashboard/assets/Metrics-C3JL1GIH.js +1 -0
  172. data/vendor/assets/dashboard/assets/NotFound-BfP9uAV9.js +1 -0
  173. data/vendor/assets/dashboard/assets/PageHeader-DbC97afG.js +1 -0
  174. data/vendor/assets/dashboard/assets/Profiles-BcaHreMe.js +1 -0
  175. data/vendor/assets/dashboard/assets/Queues-ZlBc5tyD.js +1 -0
  176. data/vendor/assets/dashboard/assets/Retries-Bg3nkeiL.js +1 -0
  177. data/vendor/assets/dashboard/assets/Scheduled-BrMZPZGl.js +1 -0
  178. data/vendor/assets/dashboard/assets/Search-B5X5pGht.js +1 -0
  179. data/vendor/assets/dashboard/assets/charts-uDyf4PN0.js +1 -0
  180. data/vendor/assets/dashboard/assets/i18n-CJeYfeVt.js +1 -0
  181. data/vendor/assets/dashboard/assets/index-B36QCAf-.css +1 -0
  182. data/vendor/assets/dashboard/assets/index-DQujgZgH.js +141 -0
  183. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-O3-5rMk3.js +1 -0
  184. data/vendor/assets/dashboard/assets/useSort-xfnwjQAu.js +1 -0
  185. data/vendor/assets/dashboard/assets/utils-BIKHwNKB.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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb): defines Sidekiq::CurrentAttributes
4
+ # (`persist`, `Save`, `Load`). Nothing is registered until `persist` is called,
5
+ # as upstream.
6
+ require 'wurk'
7
+ require 'wurk/middleware/current_attributes'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb): opts into the locale-propagating
4
+ # Sidekiq::Middleware::I18n pair, exactly as upstream's file does on require.
5
+ require 'wurk'
6
+ require 'wurk/middleware/i18n'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (sidekiq-pro.md §10): Sidekiq::Pro::Web is the same
4
+ # dashboard as Sidekiq::Web, which Wurk loads whole.
5
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb): Sidekiq 8.1's side-effect-free
4
+ # testing API. Unlike sidekiq/testing it leaves the test mode alone.
5
+ require 'wurk'
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path, deprecated upstream exactly like sidekiq/testing.
4
+ require 'wurk'
5
+
6
+ Wurk.testing!(:inline)
7
+ warn('⛔️ `require "sidekiq/testing/inline"` is deprecated and will be removed in Sidekiq 9.0. ' \
8
+ 'Use `Sidekiq.testing!(:inline)` instead.')
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (sidekiq-ent.md §2.5): Sidekiq::Periodic::ConfigTester
4
+ # ships with Wurk's cron.
5
+ require 'wurk'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (sidekiq-ent.md §9): the Limits, Periodic and History
4
+ # tabs are part of Wurk's one dashboard, loaded whole.
5
+ require 'wurk'
@@ -0,0 +1,7 @@
1
+ # rubocop:disable Naming/FileName -- the gem's own require name, hyphen included
2
+ # frozen_string_literal: true
3
+
4
+ # Drop-in require path (sidekiq-ent.md, top): Wurk ships every Enterprise
5
+ # feature in the one gem, so this only has to load it.
6
+ require 'wurk'
7
+ # rubocop:enable Naming/FileName
@@ -0,0 +1,7 @@
1
+ # rubocop:disable Naming/FileName -- the gem's own require name, hyphen included
2
+ # frozen_string_literal: true
3
+
4
+ # Drop-in require path (sidekiq-pro.md, top): Wurk ships every Pro feature in
5
+ # the one gem, so this only has to load it.
6
+ require 'wurk'
7
+ # rubocop:enable Naming/FileName
data/lib/wurk/api/fast.rb CHANGED
@@ -1,23 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../lua'
4
- require_relative '../job_record'
5
4
 
6
5
  module Wurk
7
6
  module API
8
7
  # Pro parity (§11): Lua-backed O(1)-round-trip replacements for the
9
- # ruby-side LRANGE / ZSCAN loops in Queue and SortedSet. Mixed into the
10
- # existing data API classes so the surface is `Sidekiq::Queue#delete_job(jid)`,
11
- # `Sidekiq::DeadSet#scan(pattern) { |JobRecord| … }` etc. — wire-compat
12
- # with Pro consumer code that drops in on a one-line require swap.
8
+ # ruby-side LRANGE loops in Queue. Mixed into the existing data API class
9
+ # so the surface is `Sidekiq::Queue#delete_job(jid)` — wire-compat with Pro
10
+ # consumer code that drops in on a one-line require swap.
13
11
  #
14
12
  # We don't reimplement `Queue#size` (already LLEN, unchanged per spec).
15
13
  module Fast
16
14
  # Extension to Wurk::Queue. Pure server-side delete by jid / class — no
17
- # network round-trips per match. Returns the count of payloads removed.
15
+ # network round-trips per match.
18
16
  module QueueExt
19
- # @return [Integer] payloads removed (0 when jid absent; >0 only in
20
- # the corner case of duplicate-jid corruption).
17
+ # @return [String, nil] the deleted job's JSON, or nil when no job in
18
+ # the queue has that jid — Pro's contract, so `if q.delete_job(jid)`
19
+ # branches on whether anything was removed.
21
20
  def delete_job(jid)
22
21
  raise ArgumentError, 'jid required' if jid.nil? || jid.to_s.empty?
23
22
 
@@ -28,7 +27,7 @@ module Wurk
28
27
  keys: [Keys.queue(name)],
29
28
  argv: [jid.to_s]
30
29
  )
31
- end.to_i
30
+ end
32
31
  end
33
32
 
34
33
  # @param klass [Class, String, Symbol]
@@ -47,32 +46,8 @@ module Wurk
47
46
  end.to_i
48
47
  end
49
48
  end
50
-
51
- # Extension to Wurk::SortedSet / JobSet. The base `scan(match, count)`
52
- # already yields `(value, score)` pairs via ZSCAN — Pro promotes the
53
- # surface to yield `JobRecord` directly so callers can `entry.delete`
54
- # / `entry.retry` without re-parsing JSON.
55
- #
56
- # The new behavior is enabled by *block arity*: a 1-arg block (or
57
- # block.lambda? + a 1-param signature) receives `JobRecord`; legacy
58
- # 2-arg blocks (`|value, score|`) keep the raw shape. This preserves
59
- # the existing two-arg contract used by JobSet#find_job internally.
60
- module SortedSetExt
61
- def scan(match, count = 100, &block)
62
- return enum_for(:scan, match, count) unless block
63
-
64
- if block.arity == 1
65
- super do |value, score|
66
- block.call(Wurk::SortedEntry.new(self, score, value))
67
- end
68
- else
69
- super
70
- end
71
- end
72
- end
73
49
  end
74
50
  end
75
51
  end
76
52
 
77
53
  Wurk::Queue.include(Wurk::API::Fast::QueueExt)
78
- Wurk::SortedSet.prepend(Wurk::API::Fast::SortedSetExt)
@@ -95,17 +95,40 @@ module Wurk
95
95
  reserved ? nil : existing.to_s
96
96
  end
97
97
 
98
+ # Errors that prove the write they interrupted never applied: refused
99
+ # while dialing, a starved checkout, or a reply in which Redis itself
100
+ # rejected the command.
101
+ PRE_APPLY_ERRORS = [
102
+ ::RedisClient::CannotConnectError, ::RedisClient::FailoverError,
103
+ ::RedisClient::CommandError, ::ConnectionPool::TimeoutError
104
+ ].freeze
105
+
98
106
  # Only a success is worth replaying. A rejected body is a request the
99
- # client should be able to correct and send again under the same key, and
100
- # a raise is a request whose outcome nobody knows — both release the key
101
- # rather than pinning an answer to it.
107
+ # client should be able to correct and send again under the same key, so
108
+ # it releases. A raise releases only when nothing can have reached Redis
109
+ # — before any write went out, or on an error that proves the one in
110
+ # flight never applied. Anything else (a lost reply, a bulk request that
111
+ # died after an earlier slice landed) is an outcome nobody knows: the key
112
+ # stays pending until its TTL, so the client's retry is told "in flight"
113
+ # instead of enqueueing a second copy.
102
114
  def settle(slot, fingerprint)
115
+ Thread.current[::Wurk::Client::WRITE_STATE_KEY] = :clean
103
116
  response = yield
104
117
  response[0] < 300 ? record(slot, fingerprint, response) : release(slot)
105
118
  response
106
- rescue StandardError
107
- release(slot)
119
+ rescue StandardError => e
120
+ release(slot) unless outcome_unknown?(e)
108
121
  raise
122
+ ensure
123
+ Thread.current[::Wurk::Client::WRITE_STATE_KEY] = nil
124
+ end
125
+
126
+ def outcome_unknown?(error)
127
+ case Thread.current[::Wurk::Client::WRITE_STATE_KEY]
128
+ when :applied then true
129
+ when :attempted then PRE_APPLY_ERRORS.none? { |klass| error.is_a?(klass) }
130
+ else false
131
+ end
109
132
  end
110
133
 
111
134
  # KEEPTTL so the window is the first request's, not this write's, and XX
@@ -134,7 +134,7 @@ module Wurk
134
134
  def limiters(request)
135
135
  query = Page.query!(request)
136
136
  window = Page.window(query)
137
- names = ::Wurk::Web::Enterprise::Limits.list(filter: filter!(query))
137
+ names = ::Wurk::Web::Enterprise::Limits.list(filter: filter!(query), sweep: !ReadOnly.enabled?(request))
138
138
  rows = Page.slice(names, window) { |name| limiter_row(name) }
139
139
  Response.json(200, total: names.size, page: window.page, count: window.count, limiters: rows)
140
140
  end
@@ -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