wurk 1.4.0 → 1.6.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -3
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/app/controllers/wurk/api/serializers.rb +51 -1
  5. data/app/controllers/wurk/api_controller.rb +42 -6
  6. data/app/controllers/wurk/dashboard_controller.rb +66 -10
  7. data/config/routes.rb +30 -3
  8. data/exe/wurk +6 -2
  9. data/lib/generators/wurk/install/install_generator.rb +3 -3
  10. data/lib/sidekiq/job_retry.rb +4 -0
  11. data/lib/sidekiq/manager.rb +4 -0
  12. data/lib/sidekiq/processor.rb +4 -0
  13. data/lib/wurk/api/app.rb +186 -0
  14. data/lib/wurk/api/auth.rb +167 -0
  15. data/lib/wurk/api/flows.rb +66 -0
  16. data/lib/wurk/api/idempotency.rb +168 -0
  17. data/lib/wurk/api/jobs.rb +180 -0
  18. data/lib/wurk/api/page.rb +98 -0
  19. data/lib/wurk/api/problem.rb +121 -0
  20. data/lib/wurk/api/queues.rb +126 -0
  21. data/lib/wurk/api/read_only.rb +68 -0
  22. data/lib/wurk/api/request.rb +46 -0
  23. data/lib/wurk/api/response.rb +28 -0
  24. data/lib/wurk/api/roll_up.rb +126 -0
  25. data/lib/wurk/api/router.rb +88 -0
  26. data/lib/wurk/api/serializers.rb +232 -0
  27. data/lib/wurk/api/swarm.rb +297 -0
  28. data/lib/wurk/api/throttle.rb +109 -0
  29. data/lib/wurk/api/validation.rb +306 -0
  30. data/lib/wurk/api.rb +96 -0
  31. data/lib/wurk/batch/server_middleware.rb +2 -2
  32. data/lib/wurk/batch.rb +12 -3
  33. data/lib/wurk/capsule.rb +48 -13
  34. data/lib/wurk/cli.rb +99 -4
  35. data/lib/wurk/client/buffered.rb +8 -1
  36. data/lib/wurk/client.rb +144 -35
  37. data/lib/wurk/collapse.rb +383 -0
  38. data/lib/wurk/compat.rb +19 -0
  39. data/lib/wurk/component.rb +48 -4
  40. data/lib/wurk/configuration.rb +348 -10
  41. data/lib/wurk/context.rb +1 -1
  42. data/lib/wurk/debounce.rb +125 -0
  43. data/lib/wurk/encryption.rb +6 -1
  44. data/lib/wurk/engine.rb +41 -1
  45. data/lib/wurk/errors.rb +15 -0
  46. data/lib/wurk/fetcher/capped.rb +218 -0
  47. data/lib/wurk/fetcher/reaper.rb +12 -2
  48. data/lib/wurk/fetcher/reliable.rb +307 -90
  49. data/lib/wurk/fetcher/unit_of_work.rb +100 -0
  50. data/lib/wurk/fetcher.rb +6 -0
  51. data/lib/wurk/flow/builder.rb +271 -0
  52. data/lib/wurk/flow/chain.rb +42 -0
  53. data/lib/wurk/flow/completion.rb +114 -0
  54. data/lib/wurk/flow/creation.rb +254 -0
  55. data/lib/wurk/flow/node.rb +124 -0
  56. data/lib/wurk/flow/status.rb +206 -0
  57. data/lib/wurk/flow.rb +255 -0
  58. data/lib/wurk/flow_set.rb +53 -0
  59. data/lib/wurk/health.rb +8 -7
  60. data/lib/wurk/heartbeat.rb +23 -5
  61. data/lib/wurk/job/options.rb +11 -1
  62. data/lib/wurk/job.rb +19 -0
  63. data/lib/wurk/job_logger.rb +16 -7
  64. data/lib/wurk/job_retry.rb +6 -1
  65. data/lib/wurk/job_set.rb +3 -2
  66. data/lib/wurk/job_util.rb +153 -32
  67. data/lib/wurk/keys.rb +125 -0
  68. data/lib/wurk/launcher.rb +117 -73
  69. data/lib/wurk/leader.rb +37 -7
  70. data/lib/wurk/limiter/bucket.rb +1 -1
  71. data/lib/wurk/limiter/points.rb +1 -1
  72. data/lib/wurk/logger.rb +1 -1
  73. data/lib/wurk/lua/debounce.lua +75 -0
  74. data/lib/wurk/lua/fetch_slot.lua +83 -0
  75. data/lib/wurk/lua/flow_abandon.lua +65 -0
  76. data/lib/wurk/lua/flow_advance.lua +177 -0
  77. data/lib/wurk/lua/flow_create.lua +141 -0
  78. data/lib/wurk/lua/flow_fail.lua +52 -0
  79. data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
  80. data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
  81. data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
  82. data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
  83. data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
  84. data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
  85. data/lib/wurk/lua/limiter_points_refund.lua +18 -0
  86. data/lib/wurk/lua/limiter_register.lua +27 -0
  87. data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
  88. data/lib/wurk/lua/limiter_window_status.lua +22 -0
  89. data/lib/wurk/lua/loader.rb +32 -3
  90. data/lib/wurk/lua/queue_slot.lua +83 -0
  91. data/lib/wurk/lua/refresh_slots.lua +38 -0
  92. data/lib/wurk/lua/status_write.lua +29 -0
  93. data/lib/wurk/lua/throttle_slot.lua +71 -0
  94. data/lib/wurk/lua.rb +3 -3
  95. data/lib/wurk/manager.rb +12 -0
  96. data/lib/wurk/metrics/accumulator.rb +95 -0
  97. data/lib/wurk/metrics/flusher.rb +70 -0
  98. data/lib/wurk/metrics/history.rb +132 -31
  99. data/lib/wurk/metrics/query.rb +1 -1
  100. data/lib/wurk/metrics/statsd.rb +46 -18
  101. data/lib/wurk/middleware/chain.rb +31 -14
  102. data/lib/wurk/middleware/expiry.rb +71 -10
  103. data/lib/wurk/middleware/poison_pill.rb +23 -2
  104. data/lib/wurk/middleware/status.rb +274 -0
  105. data/lib/wurk/middleware/timeout.rb +137 -0
  106. data/lib/wurk/pool_checkout.rb +10 -0
  107. data/lib/wurk/processor.rb +141 -29
  108. data/lib/wurk/profiler.rb +6 -4
  109. data/lib/wurk/queue.rb +8 -0
  110. data/lib/wurk/queue_slot.rb +285 -0
  111. data/lib/wurk/rails.rb +3 -3
  112. data/lib/wurk/redis_client_adapter.rb +1 -1
  113. data/lib/wurk/redis_pool.rb +2 -1
  114. data/lib/wurk/shutdown_gate.rb +79 -0
  115. data/lib/wurk/stats.rb +5 -1
  116. data/lib/wurk/status/progress.rb +103 -0
  117. data/lib/wurk/status/record.rb +81 -0
  118. data/lib/wurk/status.rb +155 -0
  119. data/lib/wurk/swarm/child_boot.rb +24 -4
  120. data/lib/wurk/swarm.rb +88 -14
  121. data/lib/wurk/telemetry/client_middleware.rb +59 -0
  122. data/lib/wurk/telemetry/server_middleware.rb +152 -0
  123. data/lib/wurk/telemetry.rb +137 -0
  124. data/lib/wurk/throttle.rb +142 -0
  125. data/lib/wurk/unique.rb +10 -2
  126. data/lib/wurk/version.rb +1 -1
  127. data/lib/wurk/watchdog.rb +188 -0
  128. data/lib/wurk/web/config.rb +88 -17
  129. data/lib/wurk/web/extension.rb +2 -2
  130. data/lib/wurk/web/locale_negotiator.rb +67 -0
  131. data/lib/wurk/web.rb +1 -0
  132. data/lib/wurk/worker.rb +39 -4
  133. data/lib/wurk.rb +42 -9
  134. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
  135. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
  136. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
  137. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
  138. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
  139. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
  140. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
  141. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
  142. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
  143. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
  144. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
  145. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
  146. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
  147. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
  148. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
  149. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
  150. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
  151. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
  152. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
  153. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
  154. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
  155. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
  156. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
  157. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
  158. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
  159. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
  160. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
  161. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
  162. data/vendor/assets/dashboard/index.html +42 -11
  163. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  164. metadata +105 -34
  165. data/vendor/assets/dashboard/assets/ArgsValue-D74zX0MI.js +0 -1
  166. data/vendor/assets/dashboard/assets/BatchDetail-OmC5NPgw.js +0 -1
  167. data/vendor/assets/dashboard/assets/Batches-CIpai7St.js +0 -1
  168. data/vendor/assets/dashboard/assets/Busy-A_kwSR6Q.js +0 -1
  169. data/vendor/assets/dashboard/assets/Cron-BG7HTqlp.js +0 -1
  170. data/vendor/assets/dashboard/assets/Dashboard-A_ToqHoo.js +0 -1
  171. data/vendor/assets/dashboard/assets/Dead-8J21jMyK.js +0 -1
  172. data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +0 -1
  173. data/vendor/assets/dashboard/assets/FilterBox-Fh_Ae7UW.js +0 -1
  174. data/vendor/assets/dashboard/assets/JobDetailModal-Ceng0PMB.js +0 -2
  175. data/vendor/assets/dashboard/assets/Limiters-CruDWvNZ.js +0 -1
  176. data/vendor/assets/dashboard/assets/Metrics-CIT7VCoN.js +0 -1
  177. data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +0 -1
  178. data/vendor/assets/dashboard/assets/PageHeader-C44KNMGm.js +0 -1
  179. data/vendor/assets/dashboard/assets/Profiles-xEVTyS2N.js +0 -1
  180. data/vendor/assets/dashboard/assets/Queues-D86FYohJ.js +0 -1
  181. data/vendor/assets/dashboard/assets/Retries-Bz1O1D-i.js +0 -1
  182. data/vendor/assets/dashboard/assets/Scheduled-B6h2akTu.js +0 -1
  183. data/vendor/assets/dashboard/assets/Search-OOu22e5s.js +0 -1
  184. data/vendor/assets/dashboard/assets/Skeleton-DzR7XNxz.js +0 -1
  185. data/vendor/assets/dashboard/assets/charts-BVHHGof7.js +0 -1
  186. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
  187. data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +0 -141
  188. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-dVPGEWzn.js +0 -1
  189. data/vendor/assets/dashboard/assets/useSort-BeYbztkN.js +0 -1
  190. data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'debounce'
4
+ require_relative 'job_util'
5
+ require_relative 'middleware'
6
+ require_relative 'throttle'
7
+ require_relative 'unique'
8
+
9
+ module Wurk
10
+ # The enqueue door for Wurk's two collapse policies. {Wurk::Debounce} and
11
+ # {Wurk::Throttle} are the mechanisms; this is the single option a worker
12
+ # class writes to reach them, and the one place the company they cannot keep
13
+ # is refused.
14
+ #
15
+ # sidekiq_options collapse: { policy: :debounce, wait: 5, max_wait: 60 }
16
+ # sidekiq_options collapse: { policy: :throttle, slot: 60 }
17
+ #
18
+ # ## Why `collapse:` rather than `unique:`
19
+ #
20
+ # The slice plan sketched `unique: { policy: … }`, on the reasonable grounds
21
+ # that all three policies share one identity digest and one narrowing hook
22
+ # (`sidekiq_unique_context`). That name is taken. `sidekiq-unique-jobs` v8.1.0
23
+ # — the gem this feature replaces, and one Wurk's ecosystem suite runs against
24
+ # — still recognizes `sidekiq_options unique:` as the deprecated spelling of
25
+ # its own `lock:` (`SidekiqUniqueJobs::Lock::Validator::DEPRECATED_KEYS` maps
26
+ # `:unique => :lock`). Claiming the key would make this validator reject a
27
+ # host's working pre-v6 declaration, which is the drop-in contract breaking on
28
+ # exactly the gem it is meant to make unnecessary. `collapse` is unclaimed
29
+ # there, and says what both policies do.
30
+ #
31
+ # ## One policy per class
32
+ #
33
+ # `collapse:` and `unique_for:` answer the same question differently — collapse
34
+ # a burst, or reject the duplicate — and they do not compose: a lock drops the
35
+ # very re-enqueue debounce needs in order to extend a burst, and which of the
36
+ # two won would come down to client-chain registration order. A class declares
37
+ # one; declaring both is refused where it was written.
38
+ #
39
+ # ## What a collapsed enqueue returns
40
+ #
41
+ # nil, from both policies. For a dropped throttle that is {Wurk::Throttle}'s
42
+ # documented answer and {Wurk::Unique}'s long-standing one. For a debounce it
43
+ # is the less obvious half: this push's payload really is in `schedule`, under
44
+ # this push's jid — but only until the next enqueue of the same identity
45
+ # replaces it, so a jid handed back here invites a caller to poll, or cancel, a
46
+ # member a sibling push has already ZREM'd. {Wurk::Debounce.schedule} returns
47
+ # its {Wurk::Debounce::Outcome} for code that needs the detail; the enqueue
48
+ # door does not.
49
+ module Collapse
50
+ # The job-hash key, and the `sidekiq_options` key it is written as.
51
+ OPTION = 'collapse'
52
+
53
+ # Everything refused here is a configuration mistake, and every door this
54
+ # guards already answers a bad option with an ArgumentError
55
+ # (`JobUtil.validate_track!`, `.validate_bounds!`). Subclassing keeps a host
56
+ # that rescues ArgumentError around a declaration working, while leaving the
57
+ # conflicts nameable on their own.
58
+ class ConfigurationError < ArgumentError; end
59
+
60
+ # A parsed declaration: the policy's name, plus the keyword arguments its
61
+ # module takes, ready to splat into {Wurk::Debounce.schedule} or
62
+ # {Wurk::Throttle.admit}. Parsing straight into the callee's own keywords is
63
+ # what stops this file from growing a second opinion about what `wait` means.
64
+ Policy = Struct.new(:name, :params)
65
+
66
+ # Per-policy parameter rules. `whole` marks a slot boundary, which is a
67
+ # wall-clock landmark every producer has to land on identically and so
68
+ # cannot be a fraction of a second ({Wurk::Throttle} refuses one too).
69
+ #
70
+ # Values are checked here and not left to the two modules, which raise at
71
+ # enqueue: a `slot: 1.5` that only fails the first time a rarely-exercised
72
+ # worker is pushed has already shipped.
73
+ PARAMS = {
74
+ debounce: { required: %i[wait].freeze, optional: %i[max_wait].freeze, whole: false },
75
+ throttle: { required: %i[slot].freeze, optional: [].freeze, whole: true }
76
+ }.freeze
77
+
78
+ class << self
79
+ # The collapse policy a set of job options declares, or nil for none.
80
+ #
81
+ # Parsing and validating are one pass, shared by every door: both
82
+ # `sidekiq_options` copies (worker and ActiveJob), which keep only the
83
+ # raising, and {ClientMiddleware}, which acts on the result. One definition
84
+ # means "valid where it was written" and "usable where it is read" cannot
85
+ # drift apart — the same reason {Wurk::JobUtil.positive_seconds?} is shared
86
+ # rather than reimplemented per reader.
87
+ #
88
+ # Strict on every axis, deliberately: `slot:` written on a debounce is a
89
+ # typo for `wait:`, and a silently ignored one leaves a worker collapsing
90
+ # on a default nobody chose.
91
+ #
92
+ # @param options [Hash] string-keyed job options or a job payload
93
+ # @return [Policy, nil]
94
+ # @raise [ConfigurationError]
95
+ def policy_for(options)
96
+ declared = options[OPTION]
97
+ return nil if declared.nil?
98
+
99
+ reject_unique!(options, declared)
100
+ params = symbolized(declared)
101
+ name = policy_name!(params.delete(:policy), declared)
102
+ check_keys!(name, params, declared)
103
+ check_seconds!(name, params, declared)
104
+ reject_short_cap!(params, declared) if name == :debounce
105
+ Policy.new(name, params)
106
+ end
107
+
108
+ # `push_bulk` exists to amortize one round trip across many jobs: a
109
+ # same-queue bulk of 1,000 is still the one SADD + variadic LPUSH pipeline
110
+ # a single push is. A collapse policy is a decision per job — an EVALSHA
111
+ # whose KEYS are that job's own identity — so it cannot ride that pipeline,
112
+ # and applying it per item turns the one round trip into N. Measured here,
113
+ # 1,000 jobs onto a loopback Redis, `INFO commandstats` either side:
114
+ #
115
+ # push_bulk, no policy ~14 ms 2 commands, 1 round trip
116
+ # per-item collapse ~320 ms 6,000 commands, 1,000 round trips
117
+ #
118
+ # ~22x wall clock and three orders of magnitude of round trips, and both
119
+ # grow with the batch. That is a structural cost, not a constant worth
120
+ # tuning away, which is what settles step 6 of the slice plan against
121
+ # applying policies per item.
122
+ #
123
+ # It is also meaningless in the shape bulk is used: `perform_bulk` pushes
124
+ # one class with N distinct argument lists, and distinct arguments are
125
+ # distinct identities, so nothing collapses with anything. Every jid in the
126
+ # returned array would come back nil, which in `push_bulk`'s contract means
127
+ # "middleware halted this one" — indistinguishable from a debounce that did
128
+ # write. Raising is the only outcome that is neither a silent regression
129
+ # nor a lie about what happened.
130
+ #
131
+ # @raise [ConfigurationError] when the class or the call declares a policy
132
+ def reject_bulk!(klass, declared)
133
+ return if declared.nil?
134
+
135
+ raise ConfigurationError,
136
+ "#{klass} declares `collapse: #{declared.inspect}` and cannot be enqueued with push_bulk: " \
137
+ 'a collapse policy decides per job, which would cost one Redis round trip per item where ' \
138
+ 'bulk enqueue costs one for the whole batch, and every returned jid would be nil. ' \
139
+ 'Push these jobs individually, or drop the policy on this worker.'
140
+ end
141
+
142
+ private
143
+
144
+ # On whatever options hash the caller passed, so both the class-definition
145
+ # door (the merged options, where a subclass adding `collapse:` to a
146
+ # parent's `unique_for:` becomes visible) and the payload door (a per-call
147
+ # `set(collapse:)` over a class-level `unique_for:`) are covered by the one
148
+ # check. `unique_for: false` is the documented opt-out and reads as absent,
149
+ # which is what {Wurk::Unique.coerce_ttl} already decides.
150
+ def reject_unique!(options, declared)
151
+ return unless Wurk::Unique.coerce_ttl(options['unique_for'])
152
+
153
+ raise ConfigurationError,
154
+ "#{options['class']} sets both `unique_for: #{options['unique_for'].inspect}` and " \
155
+ "`collapse: #{declared.inspect}`. They are two answers to the same question and do not " \
156
+ 'compose: the lock drops the re-enqueue a debounce needs to extend its burst, and which ' \
157
+ 'one wins would depend on client middleware registration order. Keep one.'
158
+ end
159
+
160
+ def symbolized(declared)
161
+ unless declared.is_a?(::Hash)
162
+ raise ConfigurationError,
163
+ "Job 'collapse' must be a Hash naming a policy, e.g. " \
164
+ "`collapse: { policy: :debounce, wait: 5 }`: #{declared.inspect}"
165
+ end
166
+
167
+ declared.to_h { |key, value| [key.to_sym, value] }
168
+ end
169
+
170
+ # Symbol or String, because a payload that has crossed Redis carries the
171
+ # JSON spelling of whatever the class declared.
172
+ def policy_name!(raw, declared)
173
+ name = raw.to_sym if raw.respond_to?(:to_sym)
174
+ return name if PARAMS.key?(name)
175
+
176
+ raise ConfigurationError,
177
+ "unknown collapse policy #{raw.inspect} in #{declared.inspect}; " \
178
+ "valid policies are #{PARAMS.keys.map(&:inspect).join(' and ')}"
179
+ end
180
+
181
+ # Missing and unknown are reported together: `{ policy: :debounce, slot: 5 }`
182
+ # is one mistake — `slot:` written where `wait:` belongs — and naming only
183
+ # half of it sends the reader round twice.
184
+ def check_keys!(name, params, declared)
185
+ spec = PARAMS.fetch(name)
186
+ faults = key_faults(spec, params.keys)
187
+ return if faults.empty?
188
+
189
+ raise ConfigurationError,
190
+ "collapse policy #{name.inspect} in #{declared.inspect} #{faults.join(' and ')}; " \
191
+ "it takes #{list(spec[:required] + spec[:optional])}"
192
+ end
193
+
194
+ def key_faults(spec, given)
195
+ missing = spec[:required] - given
196
+ unknown = given - spec[:required] - spec[:optional]
197
+ faults = []
198
+ faults << "requires #{list(missing)}" unless missing.empty?
199
+ faults << "does not take #{list(unknown)}" unless unknown.empty?
200
+ faults
201
+ end
202
+
203
+ def list(keys) = keys.map(&:inspect).join(', ')
204
+
205
+ def check_seconds!(name, params, declared)
206
+ whole = PARAMS.fetch(name)[:whole]
207
+ params.each do |key, value|
208
+ next if usable_seconds?(value, whole)
209
+
210
+ raise ConfigurationError,
211
+ "collapse #{name} #{key} must be a positive #{'whole ' if whole}number of " \
212
+ "seconds: #{declared.inspect}"
213
+ end
214
+ end
215
+
216
+ def usable_seconds?(value, whole)
217
+ return false unless Wurk::JobUtil.positive_seconds?(value)
218
+
219
+ !whole || (value.to_f % 1).zero?
220
+ end
221
+
222
+ # A cap below the quiet period fires every burst at `first + max_wait`
223
+ # regardless of traffic, which is a throttle wearing a debounce's name.
224
+ # {Wurk::Debounce} refuses it too; refusing it here is what makes the
225
+ # refusal land at the class rather than at the first push.
226
+ def reject_short_cap!(params, declared)
227
+ cap = params[:max_wait]
228
+ return if cap.nil? || cap.to_f >= params[:wait].to_f
229
+
230
+ raise ConfigurationError,
231
+ "collapse debounce max_wait (#{cap}) must be at least wait (#{params[:wait]}): #{declared.inspect}"
232
+ end
233
+ end
234
+
235
+ # Client middleware — the collapse decision, taken once every other
236
+ # middleware has had the payload.
237
+ #
238
+ # Prepended, and it decides *after* its yield. Both halves are load-bearing.
239
+ # Nothing in the client chain writes to Redis — {Wurk::Client#push} does that
240
+ # only once `invoke_chain` has handed a payload back — so a middleware is
241
+ # free to run the rest of the chain and then halt the push by returning nil.
242
+ # Deciding on the way out means the payload {Wurk::Debounce} stores in
243
+ # `schedule` carries everything the enrichers stamped on it (`bid`, `locale`,
244
+ # `cattr`, `traceparent`), so a debounced job really is the scheduled job a
245
+ # `perform_in` would have written rather than a stripped-down cousin. Sitting
246
+ # outermost is what keeps that true for the middleware a host installs later
247
+ # at boot: `Unique.enable!`, `Encryption.enable!` and `Telemetry.install!`
248
+ # all `add`, which appends *inside* this one.
249
+ class ClientMiddleware
250
+ include Wurk::Middleware::ClientMiddleware
251
+ # For #verify_json alone. Client walks the args of what the chain hands
252
+ # back (client.rb:156) — after this middleware has already halted it — so
253
+ # the one payload Client never verifies is the one written from in here.
254
+ include Wurk::JobUtil
255
+
256
+ def call(_worker, job, _queue, redis_pool)
257
+ # One Hash lookup for a job that declares nothing, which is every job in
258
+ # an app that uses neither policy.
259
+ return yield if job[Wurk::Collapse::OPTION].nil?
260
+
261
+ policy = Wurk::Collapse.policy_for(job)
262
+ payload = yield
263
+ return nil if payload.nil?
264
+
265
+ decide(policy, payload, redis_pool)
266
+ end
267
+
268
+ private
269
+
270
+ # The policy comes off the payload the decision hands on, and that is not
271
+ # tidying. `Scheduled::Enq#push_promoted` re-pushes a promoted job through
272
+ # {Wurk::Client#push}, which re-runs this chain: a `schedule` member still
273
+ # carrying `collapse` is debounced again on every promotion and so never
274
+ # runs at all, and an admitted throttled job's retry — promoted the same
275
+ # way, minutes later, into a slot a fresh sibling has since taken — is
276
+ # judged a second time and silently dropped. A collapse decision belongs
277
+ # to the enqueue door and is taken exactly once, on the push that made it.
278
+ #
279
+ # Identity is unaffected: {Wurk::Unique.digest_for} reads
280
+ # `[class, queue, args]`, so the next push of the same job still collapses
281
+ # into this one's key.
282
+ def decide(policy, job, pool)
283
+ reject_incompatible!(job, policy)
284
+ carried = job.except(Wurk::Collapse::OPTION)
285
+ return collapse(carried, policy, pool) if policy.name == :debounce
286
+
287
+ admit(carried, policy, pool)
288
+ end
289
+
290
+ # Returns nil unconditionally: the job is already in `schedule`, and the
291
+ # jid it is under is not the caller's to act on (see the module docs).
292
+ def collapse(job, policy, pool)
293
+ verify_json(job)
294
+ outcome = Wurk::Debounce.schedule(job, pool: pool, **policy.params)
295
+ log_collapsed(job, outcome)
296
+ nil
297
+ end
298
+
299
+ # The payload on admission, so the push continues exactly as it would with
300
+ # no policy at all; nil on a drop, halting it.
301
+ def admit(job, policy, pool)
302
+ outcome = Wurk::Throttle.admit(job, pool: pool, **policy.params)
303
+ return job if outcome.admitted?
304
+
305
+ log_dropped(job, outcome)
306
+ nil
307
+ end
308
+
309
+ def reject_incompatible!(job, policy)
310
+ reject_encrypted!(job)
311
+ return unless policy.name == :debounce
312
+
313
+ reject_batched!(job)
314
+ reject_scheduled!(job)
315
+ end
316
+
317
+ # Encryption rewrites the last argument with a fresh IV on every push, so
318
+ # the identity digest — {Wurk::Unique}'s, shared by all three policies —
319
+ # never repeats and nothing ever collapses. Same failure and same answer as
320
+ # `Unique::ClientMiddleware#reject_encrypted!`: fail the push, because
321
+ # silence here is a policy that is configured, running, and doing nothing.
322
+ def reject_encrypted!(job)
323
+ return unless job['encrypt']
324
+ return unless defined?(Wurk::Encryption) && Wurk::Encryption.enabled?
325
+
326
+ raise Wurk::Collapse::ConfigurationError,
327
+ "#{job['class']} sets both `collapse:` and `encrypt: true`, which cannot work: " \
328
+ 'encryption rewrites the last argument with a fresh IV on every push, so the ' \
329
+ 'identity digest never repeats and no two jobs ever collapse. Drop one of the two.'
330
+ end
331
+
332
+ # A debounced job is written straight into `schedule` by the Lua script,
333
+ # which is the write BATCH_SCHEDULE would otherwise have made — so the
334
+ # batch never counts a job that will nevertheless run and decrement its
335
+ # `pending` on the way out, and the batch completes early or goes negative.
336
+ # A throttled one is safe either way: admitted, it takes the ordinary
337
+ # batched path; dropped, it is simply not in the batch, exactly as a
338
+ # duplicate dropped by {Wurk::Unique} is not.
339
+ def reject_batched!(job)
340
+ return unless job['bid']
341
+
342
+ raise Wurk::Collapse::ConfigurationError,
343
+ "#{job['class']} declares a debounce policy and cannot be enqueued inside a batch: " \
344
+ 'the collapsed job is written straight to `schedule`, so the batch would never count ' \
345
+ 'a job that still decrements its pending counter when it runs.'
346
+ end
347
+
348
+ # `at` is not the caller's to set on a debounced job: the policy owns the
349
+ # fire time, and JobUtil.scheduled_member strips `at` off the stored member,
350
+ # so a `perform_in(1.hour)` here would quietly fire in `wait` seconds.
351
+ def reject_scheduled!(job)
352
+ return unless job['at']
353
+
354
+ raise Wurk::Collapse::ConfigurationError,
355
+ "#{job['class']} declares a debounce policy, which owns the fire time, so it cannot " \
356
+ 'also be scheduled with perform_in/perform_at/set(wait:). Use `wait:` and `max_wait:`.'
357
+ end
358
+
359
+ # Info, matching Unique's dropped-duplicate line: something the caller
360
+ # asked for did not happen, and the incumbent's jid is the fact a loser
361
+ # cannot recover for itself afterwards. Unguarded: Configuration#logger
362
+ # memoizes a default rather than ever handing back nil.
363
+ def log_dropped(job, outcome)
364
+ Wurk.logger.info do
365
+ "Wurk::Collapse: throttled #{job['class']} dropped " \
366
+ "(jid=#{job['jid']} blocked by jid=#{outcome.jid})"
367
+ end
368
+ end
369
+
370
+ # Debug, unlike the drop above. A burst is many pushes by construction and
371
+ # every one of them lands here, so info would be a log line per enqueue at
372
+ # exactly the rate this policy exists to absorb.
373
+ def log_collapsed(job, outcome)
374
+ return unless outcome.replaced?
375
+
376
+ Wurk.logger.debug do
377
+ "Wurk::Collapse: debounced #{job['class']} replaced the pending job " \
378
+ "(jid=#{job['jid']} fires at #{outcome.at})"
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
data/lib/wurk/compat.rb CHANGED
@@ -120,6 +120,15 @@ module Sidekiq
120
120
  Deploy = Wurk::Deploy
121
121
  Embedded = Wurk::Embedded
122
122
  Encryption = Wurk::Encryption
123
+ # A Wurk extra, not parity — aliased because nothing else claims the name.
124
+ # Checked before adding it, the way `Sidekiq::Cron` (#204) and
125
+ # `Sidekiq::Status` taught us to: upstream Sidekiq defines no `Flow` anywhere
126
+ # in `lib/` at the pinned parity SHA, RubyGems has no `sidekiq-flow` or
127
+ # `sidekiq_flow` gem, and the only `Sidekiq::Flow` in public Ruby is a
128
+ # `FlowIdMiddleware` nested under a gem's *own* `Sidekiq` module, which this
129
+ # constant cannot reach.
130
+ Flow = Wurk::Flow
131
+ FlowSet = Wurk::FlowSet
123
132
  History = Wurk::History
124
133
  IterableJob = Wurk::IterableJob
125
134
  IterableJobQuery = Wurk::IterableJobQuery
@@ -152,6 +161,16 @@ module Sidekiq
152
161
  Shutdown = Wurk::Shutdown
153
162
  SortedEntry = Wurk::SortedEntry
154
163
  Stats = Wurk::Stats
164
+ # No `Sidekiq::Status` alias on purpose, same reason as `Sidekiq::Cron`
165
+ # (#204): the sidekiq-status gem owns that namespace and opens it with
166
+ # `module Sidekiq::Status`, which resolves an existing constant rather than
167
+ # defining a fresh module — so the alias would make the gem reopen
168
+ # Wurk::Status and, loading second, silently replace `.get`/`.delete` with its
169
+ # own two-arg-shaped versions while nesting its Storage/Worker/middlewares
170
+ # inside ours. Upstream Sidekiq's only Status is `Sidekiq::Batch::Status`
171
+ # (sidekiq-pro.md §2.5), which *is* aliased; the top-level name is not a
172
+ # Sidekiq surface at all. Wurk's tracking is `Wurk::Status`, reached by name,
173
+ # and its `status:<jid>` rows are disjoint from the gem's `sidekiq:status:<jid>`.
155
174
  Testing = Wurk::Testing
156
175
  TransactionAwareClient = Wurk::TransactionAwareClient
157
176
  Queues = Wurk::Queues
@@ -37,20 +37,58 @@ module Wurk
37
37
 
38
38
  # --- identity -------------------------------------------------------
39
39
 
40
+ # Base36 `thread.object_id ^ pid` — the id in every log line and the key
41
+ # each Processor publishes its in-flight job under. Constant for the life
42
+ # of a thread inside one process and read several times per job, so it is
43
+ # memoized per thread (frozen: it is used as a Hash key, and an unfrozen
44
+ # String key is duped on every store).
45
+ #
46
+ # The pid is memoized alongside it because the thread that calls fork keeps
47
+ # its thread-locals in the child, where the pid — and therefore the tid —
48
+ # has changed. Without the guard a forked child would report the parent's
49
+ # tid and collide with it in `<identity>:work`.
50
+ #
51
+ # Thread-local, not `Thread#[]`: the latter is fiber-local, so a job that
52
+ # runs inside a Fiber (or any Enumerator) would miss the memo and allocate
53
+ # a fresh String on every read — the identity the memo exists to cache is
54
+ # the thread's, and it does not change when a fiber does.
55
+ def self.tid
56
+ thread = Thread.current
57
+ memo = thread.thread_variable_get(:wurk_tid)
58
+ pid = ::Process.pid
59
+ return memo[1] if memo && memo[0] == pid
60
+
61
+ id = (thread.object_id ^ pid).to_s(36).freeze
62
+ thread.thread_variable_set(:wurk_tid, [pid, id].freeze)
63
+ id
64
+ end
65
+
40
66
  def tid
41
- (Thread.current.object_id ^ ::Process.pid).to_s(36)
67
+ Component.tid
42
68
  end
43
69
 
44
- def hostname
70
+ def self.hostname
45
71
  ENV['DYNO'] || Socket.gethostname
46
72
  end
47
73
 
74
+ # Class-level so a plain module with no Component instance to hand — the
75
+ # queue-slot holder token, which has to name this process the same way the
76
+ # `processes` set does — reaches the one definition instead of copying the
77
+ # format. The instance methods below are the same string.
78
+ def self.identity
79
+ "#{hostname}:#{::Process.pid}:#{PROCESS_NONCE}"
80
+ end
81
+
82
+ def hostname
83
+ Component.hostname
84
+ end
85
+
48
86
  def process_nonce
49
87
  PROCESS_NONCE
50
88
  end
51
89
 
52
90
  def identity
53
- "#{hostname}:#{::Process.pid}:#{process_nonce}"
91
+ Component.identity
54
92
  end
55
93
 
56
94
  def default_tag(dir = Dir.pwd)
@@ -110,10 +148,16 @@ module Wurk
110
148
  # Spawns a named thread that runs `block` under `watchdog(name)`. The
111
149
  # parent must retain the returned Thread; otherwise GC may not, but
112
150
  # report_on_exception is disabled so we don't double-log on death.
151
+ #
152
+ # Priority resolution matches Sidekiq (component.rb:44-48): explicit
153
+ # argument, then `config.thread_priority`, then -1. Ruby's default of 0
154
+ # buys a 100ms timeslice; each negative step halves it, so -1 keeps a
155
+ # CPU-heavy capsule from starving its siblings for a whole tick.
113
156
  def safe_thread(name, priority: nil, &block)
157
+ resolved = priority || config.thread_priority || DEFAULT_THREAD_PRIORITY
114
158
  Thread.new do
115
159
  Thread.current.name = name
116
- Thread.current.priority = priority || DEFAULT_THREAD_PRIORITY
160
+ Thread.current.priority = resolved
117
161
  Thread.current.report_on_exception = false
118
162
  watchdog(name, &block)
119
163
  end