wurk 1.5.0 → 1.7.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 (173) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/app/controllers/wurk/api/serializers.rb +51 -1
  4. data/app/controllers/wurk/api_controller.rb +42 -6
  5. data/app/controllers/wurk/dashboard_controller.rb +67 -10
  6. data/config/routes.rb +30 -3
  7. data/exe/wurk +6 -2
  8. data/lib/generators/wurk/install/install_generator.rb +3 -3
  9. data/lib/sidekiq/job_retry.rb +4 -0
  10. data/lib/sidekiq/manager.rb +4 -0
  11. data/lib/sidekiq/processor.rb +4 -0
  12. data/lib/wurk/api/app.rb +186 -0
  13. data/lib/wurk/api/auth.rb +167 -0
  14. data/lib/wurk/api/flows.rb +66 -0
  15. data/lib/wurk/api/idempotency.rb +168 -0
  16. data/lib/wurk/api/jobs.rb +180 -0
  17. data/lib/wurk/api/page.rb +98 -0
  18. data/lib/wurk/api/problem.rb +121 -0
  19. data/lib/wurk/api/queues.rb +126 -0
  20. data/lib/wurk/api/read_only.rb +68 -0
  21. data/lib/wurk/api/request.rb +46 -0
  22. data/lib/wurk/api/response.rb +28 -0
  23. data/lib/wurk/api/roll_up.rb +126 -0
  24. data/lib/wurk/api/router.rb +88 -0
  25. data/lib/wurk/api/serializers.rb +232 -0
  26. data/lib/wurk/api/swarm.rb +297 -0
  27. data/lib/wurk/api/throttle.rb +109 -0
  28. data/lib/wurk/api/validation.rb +306 -0
  29. data/lib/wurk/api.rb +96 -0
  30. data/lib/wurk/batch/server_middleware.rb +2 -2
  31. data/lib/wurk/batch.rb +12 -3
  32. data/lib/wurk/capsule.rb +8 -1
  33. data/lib/wurk/cli.rb +110 -8
  34. data/lib/wurk/client/buffered.rb +1 -1
  35. data/lib/wurk/client.rb +42 -3
  36. data/lib/wurk/collapse.rb +383 -0
  37. data/lib/wurk/command_builder.rb +49 -0
  38. data/lib/wurk/compat.rb +19 -0
  39. data/lib/wurk/component.rb +16 -3
  40. data/lib/wurk/configuration.rb +303 -4
  41. data/lib/wurk/debounce.rb +125 -0
  42. data/lib/wurk/encryption.rb +6 -1
  43. data/lib/wurk/fetcher/capped.rb +218 -0
  44. data/lib/wurk/fetcher/reliable.rb +22 -66
  45. data/lib/wurk/fetcher/unit_of_work.rb +107 -0
  46. data/lib/wurk/flow/builder.rb +271 -0
  47. data/lib/wurk/flow/chain.rb +42 -0
  48. data/lib/wurk/flow/completion.rb +114 -0
  49. data/lib/wurk/flow/creation.rb +254 -0
  50. data/lib/wurk/flow/node.rb +124 -0
  51. data/lib/wurk/flow/status.rb +206 -0
  52. data/lib/wurk/flow.rb +255 -0
  53. data/lib/wurk/flow_set.rb +53 -0
  54. data/lib/wurk/health.rb +8 -7
  55. data/lib/wurk/heartbeat.rb +21 -3
  56. data/lib/wurk/job/options.rb +11 -1
  57. data/lib/wurk/job.rb +19 -0
  58. data/lib/wurk/job_logger.rb +6 -2
  59. data/lib/wurk/job_retry.rb +6 -1
  60. data/lib/wurk/job_util.rb +110 -9
  61. data/lib/wurk/keys.rb +125 -0
  62. data/lib/wurk/launcher.rb +1 -1
  63. data/lib/wurk/leader.rb +1 -1
  64. data/lib/wurk/limiter/bucket.rb +1 -1
  65. data/lib/wurk/limiter/points.rb +1 -1
  66. data/lib/wurk/lua/debounce.lua +75 -0
  67. data/lib/wurk/lua/fetch_slot.lua +83 -0
  68. data/lib/wurk/lua/flow_abandon.lua +65 -0
  69. data/lib/wurk/lua/flow_advance.lua +177 -0
  70. data/lib/wurk/lua/flow_create.lua +141 -0
  71. data/lib/wurk/lua/flow_fail.lua +52 -0
  72. data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
  73. data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
  74. data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
  75. data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
  76. data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
  77. data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
  78. data/lib/wurk/lua/limiter_points_refund.lua +18 -0
  79. data/lib/wurk/lua/limiter_register.lua +27 -0
  80. data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
  81. data/lib/wurk/lua/limiter_window_status.lua +22 -0
  82. data/lib/wurk/lua/loader.rb +23 -0
  83. data/lib/wurk/lua/queue_slot.lua +83 -0
  84. data/lib/wurk/lua/refresh_slots.lua +38 -0
  85. data/lib/wurk/lua/status_write.lua +29 -0
  86. data/lib/wurk/lua/throttle_slot.lua +71 -0
  87. data/lib/wurk/lua.rb +3 -3
  88. data/lib/wurk/manager.rb +12 -0
  89. data/lib/wurk/metrics/history.rb +34 -2
  90. data/lib/wurk/metrics/query.rb +1 -1
  91. data/lib/wurk/metrics/statsd.rb +14 -0
  92. data/lib/wurk/middleware/expiry.rb +71 -10
  93. data/lib/wurk/middleware/status.rb +274 -0
  94. data/lib/wurk/middleware/timeout.rb +137 -0
  95. data/lib/wurk/pid_cache.rb +43 -0
  96. data/lib/wurk/processor.rb +66 -22
  97. data/lib/wurk/profiler.rb +5 -3
  98. data/lib/wurk/queue_slot.rb +285 -0
  99. data/lib/wurk/rails.rb +3 -3
  100. data/lib/wurk/redis_pool.rb +5 -2
  101. data/lib/wurk/status/progress.rb +103 -0
  102. data/lib/wurk/status/record.rb +81 -0
  103. data/lib/wurk/status.rb +155 -0
  104. data/lib/wurk/telemetry/client_middleware.rb +59 -0
  105. data/lib/wurk/telemetry/server_middleware.rb +152 -0
  106. data/lib/wurk/telemetry.rb +137 -0
  107. data/lib/wurk/throttle.rb +142 -0
  108. data/lib/wurk/unique.rb +10 -2
  109. data/lib/wurk/version.rb +1 -1
  110. data/lib/wurk/watchdog.rb +188 -0
  111. data/lib/wurk/web/config.rb +77 -10
  112. data/lib/wurk/web/extension.rb +12 -4
  113. data/lib/wurk/web/locale_negotiator.rb +67 -0
  114. data/lib/wurk/web.rb +1 -0
  115. data/lib/wurk/worker.rb +39 -4
  116. data/lib/wurk.rb +42 -1
  117. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
  118. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
  119. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
  120. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
  121. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
  122. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
  123. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
  124. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
  125. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
  126. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
  127. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
  128. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
  129. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
  130. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
  131. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
  132. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
  133. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
  134. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
  135. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
  136. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
  137. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
  138. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
  139. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
  140. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
  141. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
  142. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
  143. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
  144. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
  145. data/vendor/assets/dashboard/index.html +42 -11
  146. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  147. metadata +102 -33
  148. data/vendor/assets/dashboard/assets/ArgsValue-CcR2ya6e.js +0 -1
  149. data/vendor/assets/dashboard/assets/BatchDetail-CUXJUQ3Q.js +0 -1
  150. data/vendor/assets/dashboard/assets/Batches-Cxan6Ngw.js +0 -1
  151. data/vendor/assets/dashboard/assets/Busy-DC5EGM0g.js +0 -1
  152. data/vendor/assets/dashboard/assets/Cron-Dlt8tXJA.js +0 -1
  153. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +0 -1
  154. data/vendor/assets/dashboard/assets/Dead-dZ7VGlKS.js +0 -1
  155. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +0 -1
  156. data/vendor/assets/dashboard/assets/FilterBox-CO3aYWIq.js +0 -1
  157. data/vendor/assets/dashboard/assets/JobDetailModal-DSWbT6G0.js +0 -2
  158. data/vendor/assets/dashboard/assets/Limiters-Cb4PKXNR.js +0 -1
  159. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +0 -1
  160. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +0 -1
  161. data/vendor/assets/dashboard/assets/PageHeader-fPrCcp_-.js +0 -1
  162. data/vendor/assets/dashboard/assets/Profiles-BnS82nR_.js +0 -1
  163. data/vendor/assets/dashboard/assets/Queues-CIyPevOy.js +0 -1
  164. data/vendor/assets/dashboard/assets/Retries-DopwXkXl.js +0 -1
  165. data/vendor/assets/dashboard/assets/Scheduled-1-Z7i1zE.js +0 -1
  166. data/vendor/assets/dashboard/assets/Search-ByA6eTma.js +0 -1
  167. data/vendor/assets/dashboard/assets/Skeleton-bC7HfQ9r.js +0 -1
  168. data/vendor/assets/dashboard/assets/charts-CLLzJ7vK.js +0 -1
  169. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +0 -141
  170. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
  171. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-DpBjkf6_.js +0 -1
  172. data/vendor/assets/dashboard/assets/useSort-DvpwuNQE.js +0 -1
  173. data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
@@ -531,7 +531,7 @@ module Wurk
531
531
  # next push — this is a Wurk extension for issue #19's "Background
532
532
  # drain thread flushes on reconnect" so producer-stopped-mid-outage
533
533
  # buffers don't sit idle until next push.
534
- def reliable_push_drainer(interval: Buffered::Drainer::DEFAULT_INTERVAL)
534
+ def reliable_push_drainer(interval: Buffered::Drainer::DEFAULT_INTERVAL) # rubocop:disable Naming/PredicateMethod
535
535
  Buffered.install!
536
536
  Buffered.start_drainer!(interval: interval)
537
537
  true
data/lib/wurk/client.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 'iterable_job'
4
4
  require_relative 'job_util'
5
5
  require_relative 'lua'
6
+ require_relative 'status'
6
7
 
7
8
  module Wurk
8
9
  # Enqueue interface. Pipelined LPUSH / ZADD writes against the canonical
@@ -17,7 +18,7 @@ module Wurk
17
18
  # Wurk::Client.new.push_bulk("class" => "MyJob", "args" => [[1], [2], [3]])
18
19
  #
19
20
  # Spec: docs/target/sidekiq-free.md §7.
20
- class Client
21
+ class Client # rubocop:disable Metrics/ClassLength
21
22
  include JobUtil
22
23
 
23
24
  # Sidekiq mirrors these exactly. Tests against the upstream parity suite
@@ -86,9 +87,10 @@ module Wurk
86
87
  validate_bulk_shape!(items, args)
87
88
  return [] if args.empty?
88
89
 
90
+ base = bulk_base(items)
91
+ reject_bulk_collapse!(base)
89
92
  at_values = expand_at(items, args.size)
90
93
  batch_sz = items['batch_size'] || items[:batch_size] || (at_values ? SCHEDULED_BATCH_SIZE : DEFAULT_BATCH_SIZE)
91
- base = bulk_base(items)
92
94
  flush_bulk(args, at_values, base, batch_sz)
93
95
  end
94
96
 
@@ -198,6 +200,19 @@ module Wurk
198
200
  base
199
201
  end
200
202
 
203
+ # Up front, before the first slice is written — a policy discovered halfway
204
+ # through would leave the slices already flushed in Redis. Two lookups and no
205
+ # allocation on the untouched path.
206
+ #
207
+ # `class_defaults_for` is the same resolution #normalize_item does per item,
208
+ # so this sees a policy in exactly the cases the middleware would: a bulk
209
+ # pushed by class *name* never merges class options at all, and so never
210
+ # carries one. Spelled out in {Wurk::Collapse.reject_bulk!}.
211
+ def reject_bulk_collapse!(base)
212
+ key = Wurk::Collapse::OPTION
213
+ Wurk::Collapse.reject_bulk!(base['class'], base[key] || class_defaults_for(base['class'])[key])
214
+ end
215
+
201
216
  def flush_bulk(args, at_values, base, batch_size)
202
217
  jids = []
203
218
  args.each_slice(batch_size).with_index do |slice, slice_index|
@@ -324,7 +339,7 @@ module Wurk
324
339
 
325
340
  def push_scheduled(conn, payloads)
326
341
  args = payloads.flat_map do |hash|
327
- [hash['at'].to_s, Wurk.dump_json(hash.except('enqueued_at', 'at'))]
342
+ [hash['at'].to_s, JobUtil.scheduled_member(hash)]
328
343
  end
329
344
  conn.call('ZADD', 'schedule', *args)
330
345
  end
@@ -425,10 +440,29 @@ module Wurk
425
440
  conn.pipelined do |pipe|
426
441
  pipe.call('SADD', 'queues', queue)
427
442
  pipe.call('LPUSH', "queue:#{queue}", *serialized)
443
+ track_enqueued(pipe, jobs, now)
428
444
  end
429
445
  mark_delivered(jobs)
430
446
  end
431
447
 
448
+ # Ride the pipeline that is already open for the queue write, so a tracked
449
+ # push costs the same round trip an untracked one does. An app that never
450
+ # opts in pays one Hash lookup per payload here and appends nothing: the
451
+ # plain push stays exactly the two commands (SADD + LPUSH) it has always
452
+ # been, which is what `rake bench:command_count_tracked_off` asserts.
453
+ #
454
+ # The TTL is resolved once per group, and only when something is actually
455
+ # tracked — an untracked push never reads the config at all.
456
+ def track_enqueued(pipe, jobs, now)
457
+ ttl = nil
458
+ jobs.each do |job|
459
+ next unless job['track']
460
+
461
+ ttl ||= Wurk::Status.default_ttl(@config)
462
+ Wurk::Status.enqueued(pipe, job, now, ttl: ttl)
463
+ end
464
+ end
465
+
432
466
  # Batched jobs route through BATCH_PUSH: increments b-<bid> total+pending,
433
467
  # SADDs jid into the live set, registers the queue, LPUSHes the payload —
434
468
  # all atomically. The Lua binds per-job KEYS, so grouping N jobs into one
@@ -452,6 +486,11 @@ module Wurk
452
486
  argv: [j['queue'], j['jid'], Wurk.dump_json(j), j['bid'], Wurk::Batch::DEFAULT_EXPIRY_SECONDS]
453
487
  )
454
488
  end
489
+ # Same pipeline, so a batched tracked push keeps its one round trip too.
490
+ # This slice is the one that gets replayed on NOSCRIPT (see
491
+ # #eval_batched_slice); HSET + EXPIRE are idempotent, so a replay writes
492
+ # the same row twice rather than double-counting anything.
493
+ track_enqueued(conn, payloads, now)
455
494
  end
456
495
 
457
496
  # Scheduled batched jobs route through BATCH_SCHEDULE: the SADD-guarded
@@ -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
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis-client'
4
+
5
+ module Wurk
6
+ # Fast path for redis-client's command normalization.
7
+ #
8
+ # `RedisClient::CommandBuilder.generate` splices Hash arguments with a
9
+ # `flat_map` and then stringifies Symbols/Integers/Floats with a `map!` —
10
+ # two array allocations and two per-element type dispatches for a command
11
+ # whose arguments are already Strings. Every command Wurk sends per job is
12
+ # exactly that shape (LMOVE, LREM, DEL, LPUSH, SADD), and the fetch pipeline
13
+ # sends three of them. Measured ~4x faster on those commands (2.2µs → 0.6µs
14
+ # each) with `bench/command_builder.rb`.
15
+ #
16
+ # Anything else — a Hash to splice, a Symbol or a number to stringify,
17
+ # keyword arguments, an empty command — falls straight through to
18
+ # redis-client's own builder. This is a shortcut, never a second
19
+ # implementation of the semantics, so a host app calling
20
+ # `Sidekiq.redis { |c| c.call("HSET", key, hash) }` is unaffected.
21
+ #
22
+ # The array is duped rather than handed back: `call_v` and the pipelined
23
+ # forms pass the caller's own array through, and redis-client's builder
24
+ # always returns a fresh one that middleware is free to mutate in place.
25
+ module CommandBuilder
26
+ module_function
27
+
28
+ def generate(args, kwargs = nil)
29
+ return args.dup if fast?(args, kwargs)
30
+
31
+ ::RedisClient::CommandBuilder.generate(args, kwargs)
32
+ end
33
+
34
+ # Split out, and public, because the two branches produce byte-identical
35
+ # output by construction — which leaves allocation count as the only
36
+ # runtime difference between "the shortcut fired" and "it silently didn't",
37
+ # and that is not something a test can assert safely in a process running
38
+ # other suites. So the decision itself is what the suite asserts.
39
+ #
40
+ # `kwargs` is nil only from `call_v` and the scan helpers. Every `call`,
41
+ # `blocking_call` and pipelined `call` arrives with `**kwargs` already
42
+ # splatted into a Hash — EMPTY when the caller passed no keywords — so a
43
+ # guard that only tested `.nil?` sent the entire hot path down the slow
44
+ # branch while every direct-call test still passed.
45
+ def fast?(args, kwargs)
46
+ (kwargs.nil? || kwargs.empty?) && !args.empty? && args.all?(String)
47
+ end
48
+ end
49
+ 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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'socket'
4
4
  require 'securerandom'
5
+ require_relative 'pid_cache'
5
6
 
6
7
  module Wurk
7
8
  # Shared mixin for runtime components (Launcher, Manager, Processor, Fetcher,
@@ -55,7 +56,7 @@ module Wurk
55
56
  def self.tid
56
57
  thread = Thread.current
57
58
  memo = thread.thread_variable_get(:wurk_tid)
58
- pid = ::Process.pid
59
+ pid = PidCache.pid
59
60
  return memo[1] if memo && memo[0] == pid
60
61
 
61
62
  id = (thread.object_id ^ pid).to_s(36).freeze
@@ -67,16 +68,28 @@ module Wurk
67
68
  Component.tid
68
69
  end
69
70
 
70
- def hostname
71
+ def self.hostname
71
72
  ENV['DYNO'] || Socket.gethostname
72
73
  end
73
74
 
75
+ # Class-level so a plain module with no Component instance to hand — the
76
+ # queue-slot holder token, which has to name this process the same way the
77
+ # `processes` set does — reaches the one definition instead of copying the
78
+ # format. The instance methods below are the same string.
79
+ def self.identity
80
+ "#{hostname}:#{::Process.pid}:#{PROCESS_NONCE}"
81
+ end
82
+
83
+ def hostname
84
+ Component.hostname
85
+ end
86
+
74
87
  def process_nonce
75
88
  PROCESS_NONCE
76
89
  end
77
90
 
78
91
  def identity
79
- "#{hostname}:#{::Process.pid}:#{process_nonce}"
92
+ Component.identity
80
93
  end
81
94
 
82
95
  def default_tag(dir = Dir.pwd)