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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../cron'
5
+
6
+ module Wurk
7
+ module Import
8
+ # Reads the schedule sidekiq-cron keeps in Redis and maps each entry onto a
9
+ # native periodic loop (`Wurk::Cron::Loop`). `rake wurk:import:cron` drives
10
+ # it: a dry run prints the plan plus a `config.periodic` block to paste into
11
+ # code; `APPLY=1` persists the loops straight into the `periodic` registry.
12
+ #
13
+ # sidekiq-cron's layout (v1.12+ / 2.x, namespaced): `cron_jobs:<namespace>`
14
+ # is a SET of `cron_job:<namespace>:<name>` HASH keys. Releases before
15
+ # namespaces used one `cron_jobs` SET of `cron_job:<name>` keys; both are
16
+ # read. Nothing here writes or deletes a sidekiq-cron key, so a rollback to
17
+ # Sidekiq still finds its schedule intact.
18
+ class SidekiqCron
19
+ LEGACY_SET = 'cron_jobs'
20
+ NAMESPACED_SETS = 'cron_jobs:*'
21
+ GLOBALID_KEY = '_sc_globalid'
22
+ TRUE_FLAG = /\A(?:true|t|yes|y|1)\z/i
23
+ ZONE_TOKEN = %r{\A[A-Za-z][A-Za-z0-9_+\-/]*\z}
24
+
25
+ # One sidekiq-cron entry and what it becomes. `loop` is nil exactly when
26
+ # `skip_reason` says why the entry cannot be carried over unchanged.
27
+ Entry = Struct.new(:key, :namespace, :name, :source, :loop, :skip_reason, :warnings, keyword_init: true) do
28
+ def importable? = !loop.nil?
29
+ end
30
+
31
+ def entries
32
+ rows = redis do |c|
33
+ keys = schedule_keys(c)
34
+ keys.map { |key| [key, c.call('HGETALL', key).to_h] }
35
+ end
36
+ rows.reject { |(_key, h)| h.empty? }.map { |key, h| build_entry(key, h) }
37
+ end
38
+
39
+ # Persists every importable entry and returns the entries it wrote.
40
+ # Idempotent: a loop's lid is a hash of its schedule, class and options,
41
+ # so a second run rewrites the same keys, and `paused` is only seeded
42
+ # (Cron.persist uses HSETNX), so a dashboard pause survives a re-import.
43
+ def apply!(list = entries)
44
+ list.select(&:importable?).each { |entry| Cron.persist(entry.loop) }
45
+ end
46
+
47
+ def registered?(lid)
48
+ redis { |c| c.call('SISMEMBER', Cron::PERIODIC_KEY, lid) }.to_i == 1
49
+ end
50
+
51
+ # What `rake wurk:import:cron` prints. Returns the entries it found.
52
+ def run(apply:, out: $stdout)
53
+ list = entries
54
+ if list.empty?
55
+ out.puts "No sidekiq-cron entries found (looked for #{NAMESPACED_SETS} and #{LEGACY_SET})."
56
+ return list
57
+ end
58
+
59
+ report(list, out)
60
+ if apply
61
+ written = apply!(list)
62
+ out.puts "\nRegistered #{written.size} loop(s) in the `#{Cron::PERIODIC_KEY}` registry. " \
63
+ 'sidekiq-cron keys were left untouched.'
64
+ else
65
+ out.puts "\nDry run: nothing was written. Re-run with APPLY=1 to register the importable loops in Redis."
66
+ end
67
+ list
68
+ end
69
+
70
+ private
71
+
72
+ def report(list, out)
73
+ importable = list.select(&:importable?)
74
+ out.puts "sidekiq-cron entries: #{list.size} (#{importable.size} importable, " \
75
+ "#{list.size - importable.size} skipped)\n\n"
76
+ list.each { |entry| report_entry(entry, out) }
77
+ return if importable.empty?
78
+
79
+ out.puts "\nTo keep these in code (recommended), paste into config/initializers/wurk.rb:\n\n"
80
+ out.puts snippet(importable)
81
+ end
82
+
83
+ def report_entry(entry, out)
84
+ id = "#{entry.namespace}/#{entry.name}"
85
+ if entry.importable?
86
+ lp = entry.loop
87
+ state = registered?(lp.lid) ? 'already registered' : 'new'
88
+ out.puts " import #{id} #{lp.schedule.inspect} #{lp.klass} -> lid #{lp.lid} (#{state})"
89
+ else
90
+ out.puts " skip #{id} #{entry.skip_reason}"
91
+ end
92
+ entry.warnings.each { |w| out.puts " warn #{id} #{w}" }
93
+ end
94
+
95
+ def snippet(entries)
96
+ lines = entries.map do |entry|
97
+ lp = entry.loop
98
+ opts = lp.options.map { |k, v| "#{k}: #{v.inspect}" }
99
+ opts << "tz: #{lp.tz_name.inspect}" if lp.tz_name
100
+ " mgr.register(#{[lp.schedule.inspect, lp.klass.inspect, *opts].join(', ')})"
101
+ end
102
+ ['Wurk.configure_server do |config|', ' config.periodic do |mgr|', *lines, ' end', 'end'].join("\n")
103
+ end
104
+
105
+ def schedule_keys(conn)
106
+ sets = scan(conn, NAMESPACED_SETS)
107
+ sets << LEGACY_SET if conn.call('EXISTS', LEGACY_SET).to_i == 1
108
+ sets.flat_map { |set| conn.call('SMEMBERS', set) }.uniq.sort
109
+ end
110
+
111
+ def scan(conn, pattern)
112
+ cursor = '0'
113
+ found = []
114
+ loop do
115
+ cursor, batch = conn.call('SCAN', cursor, 'MATCH', pattern, 'COUNT', 500)
116
+ found.concat(batch)
117
+ break if cursor == '0'
118
+ end
119
+ found.select { |key| conn.call('TYPE', key) == 'set' }.sort
120
+ end
121
+
122
+ def build_entry(key, h)
123
+ entry = Entry.new(key: key, namespace: blank(h['namespace']) || 'default', name: h['name'].to_s,
124
+ source: blank(h['source']) || 'dynamic', warnings: [])
125
+ entry.skip_reason = skip_reason(h)
126
+ entry.loop = build_loop(h, entry) unless entry.skip_reason
127
+ entry
128
+ rescue ArgumentError => e
129
+ entry.loop = nil
130
+ entry.skip_reason = e.message
131
+ entry
132
+ end
133
+
134
+ def skip_reason(hash)
135
+ return 'no class (`klass`) recorded' if blank(hash['klass'] || hash['class']).nil?
136
+ return 'no cron expression recorded' if blank(hash['cron']).nil?
137
+ return '`date_as_argument` appends the enqueue time to args; Wurk loops have no equivalent' if
138
+ flag?(hash['date_as_argument'])
139
+ return 'args carry a serialized GlobalID; Wurk loops pass args verbatim' if
140
+ hash['args'].to_s.include?(GLOBALID_KEY)
141
+
142
+ nil
143
+ end
144
+
145
+ def build_loop(hash, entry)
146
+ schedule, tz = split_zone(hash['cron'].strip)
147
+ validate_schedule!(schedule)
148
+ message = parse_json(hash['message'])
149
+ Cron::Loop.new(schedule: schedule, klass: (hash['klass'] || hash['class']).to_s, tz: tz,
150
+ options: options_for(hash, message, entry))
151
+ end
152
+
153
+ def validate_schedule!(schedule)
154
+ Cron::Parser.new(schedule)
155
+ rescue ArgumentError => e
156
+ raise ArgumentError, "unsupported schedule #{schedule.inspect} (#{e.message}); Wurk takes a " \
157
+ '5-field crontab or an @alias, not fugit seconds or natural language'
158
+ end
159
+
160
+ # fugit accepts a trailing zone (`0 5 * * * Europe/Paris`); Wurk takes the
161
+ # zone as `tz:`. A sixth token that is not a zone is fugit's seconds field
162
+ # or natural language, which the native 5-field parser rejects.
163
+ def split_zone(cron)
164
+ tokens = cron.split
165
+ return [cron, nil] unless tokens.size == 6 && tokens.last.match?(ZONE_TOKEN) && tokens.last.match?(/[A-Za-z]/)
166
+
167
+ zone = tokens.last
168
+ raise ArgumentError, "unknown timezone #{zone.inspect}" unless Cron::Parser.resolve_zone(zone)
169
+
170
+ [tokens.first(5).join(' '), zone]
171
+ end
172
+
173
+ def options_for(hash, message, entry)
174
+ add_warnings(hash, entry)
175
+ args = args_for(hash['args'])
176
+ opts = { 'label' => label_for(entry) }
177
+ opts['args'] = args unless args.empty?
178
+ opts.merge!(message_options(message))
179
+ opts['paused'] = true if hash['status'] == 'disabled'
180
+ opts
181
+ end
182
+
183
+ # The label is part of the loop's lid, so it has to carry the namespace:
184
+ # sidekiq-cron keys an entry by namespace + name, and `nightly` in
185
+ # `billing` and in `default` are two jobs that must stay two loops. The
186
+ # default namespace (and the pre-namespace legacy set) keeps the bare
187
+ # name, so an entry imported before this keeps its lid on re-import.
188
+ def label_for(entry)
189
+ entry.namespace == 'default' ? entry.name : "#{entry.namespace}/#{entry.name}"
190
+ end
191
+
192
+ # sidekiq-cron pushes the queue and retry frozen into `message` when the
193
+ # entry was saved, not the worker's current options.
194
+ def message_options(message)
195
+ opts = {}
196
+ opts['queue'] = message['queue'] if blank(message['queue'])
197
+ opts['retry'] = retry_value(message['retry']) unless message['retry'].nil?
198
+ opts
199
+ end
200
+
201
+ def add_warnings(hash, entry)
202
+ if blank(hash['queue_name_prefix'])
203
+ entry.warnings << "ActiveJob `queue_name_prefix` #{hash['queue_name_prefix'].inspect} is not carried over"
204
+ end
205
+ return unless flag?(hash['symbolize_args'])
206
+
207
+ entry.warnings << '`symbolize_args` is not carried over; hash args arrive with string keys'
208
+ end
209
+
210
+ # sidekiq-cron stores args as a JSON string, falling back to `[*raw]` when
211
+ # it does not parse; a bare Hash becomes the single argument.
212
+ def args_for(raw)
213
+ return [] if blank(raw).nil?
214
+
215
+ parsed = JSON.parse(raw)
216
+ parsed.is_a?(Array) ? parsed : [parsed]
217
+ rescue JSON::ParserError
218
+ [raw]
219
+ end
220
+
221
+ def retry_value(value)
222
+ return value unless value.is_a?(String)
223
+ return true if value == 'true'
224
+ return false if value == 'false'
225
+
226
+ value.match?(/\A\d+\z/) ? value.to_i : value
227
+ end
228
+
229
+ def parse_json(raw)
230
+ return {} if blank(raw).nil?
231
+
232
+ parsed = JSON.parse(raw)
233
+ parsed.is_a?(Hash) ? parsed : {}
234
+ rescue JSON::ParserError
235
+ {}
236
+ end
237
+
238
+ def flag?(value) = value == true || value.to_s.match?(TRUE_FLAG)
239
+
240
+ def blank(value)
241
+ str = value&.to_s
242
+ str.nil? || str.strip.empty? ? nil : str
243
+ end
244
+
245
+ def redis(&) = Wurk.redis(&)
246
+ end
247
+ end
248
+ end
@@ -20,15 +20,34 @@ module Wurk
20
20
  base.extend(ClassMethods)
21
21
  end
22
22
 
23
+ # Also the options half of {Wurk::Worker::ClassMethods}, so an ActiveJob
24
+ # class configuring a Wurk option is told about a bad value at the same
25
+ # place a plain worker is.
23
26
  module ClassMethods
24
- # Deliberately identical to Wurk::Worker::ClassMethods#sidekiq_options,
25
- # validation included: an ActiveJob class configuring a Wurk option
26
- # must be told about a bad value at the same place a plain worker is.
27
+ # Set per-class job options (merged over any inherited options).
28
+ #
29
+ # @example
30
+ # sidekiq_options queue: "mailers", retry: 3, unique_for: 10.minutes
31
+ # @example Opt into Wurk::Status tracking
32
+ # sidekiq_options track: true
33
+ # @example Bound one attempt, and the job as a whole
34
+ # sidekiq_options timeout: 30, deadline: 5.minutes
35
+ # @example Collapse a burst of enqueues into one job
36
+ # sidekiq_options collapse: { policy: :debounce, wait: 5, max_wait: 60 }
37
+ # @param opts [Hash] any of `queue:`, `retry:`, `dead:`, `backtrace:`,
38
+ # `expires_in:`, `tags:`, `pool:`, `unique_for:`, `track:`, `timeout:`,
39
+ # `deadline:`, `collapse:`, … (see the migration guide's sidekiq_options
40
+ # table for the full set)
41
+ # @return [Hash] the merged, string-keyed options hash
27
42
  def sidekiq_options(opts = {})
28
43
  stringified = opts.transform_keys(&:to_s)
29
44
  Wurk::JobUtil.validate_track!(stringified['track'], stringified) if stringified.key?('track')
30
45
  Wurk::JobUtil.validate_bounds!(stringified)
31
46
  merged = get_sidekiq_options.merge(stringified)
47
+ # On the merged options rather than the new ones, and before the
48
+ # assign: a subclass adding `collapse:` to a parent's `unique_for:`
49
+ # has declared both, and only the merge can see it. Raising first
50
+ # leaves the class holding the options it had.
32
51
  Wurk::Collapse.policy_for(merged)
33
52
  @sidekiq_options_hash = merged
34
53
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'redact'
4
+
3
5
  module Wurk
4
6
  # Wraps the per-job execution span. Logs "start"/"done"/"fail" at INFO,
5
7
  # pushes :elapsed into Wurk::Context so the logger formatter can pick it
6
8
  # up, and prepares the thread-local context hash (jid, class, plus
7
9
  # config[:logged_job_attributes]).
8
10
  #
9
- # Two entry points, called from Processor#process in this order:
11
+ # Two entry points, called from Processor#dispatch in this order:
10
12
  # 1. prepare(job_hash) { ... } → sets thread-local context, applies
11
13
  # per-job log_level, yields to the rest of dispatch.
12
14
  # 2. call(item, queue) { ... } → wraps the actual perform with the
@@ -24,7 +26,14 @@ module Wurk
24
26
  # #context_hash runs it per job only to walk a list that cannot change
25
27
  # after boot (Configuration freezes at launch).
26
28
  @logged_attributes = Array(@config[:logged_job_attributes]).map { |attr| [attr, attr.to_sym] }.freeze
27
- @skip = !!@config[:skip_default_job_logging]
29
+ # A host logger need not be a ::Logger >= 1.6 (SemanticLogger, a
30
+ # broadcast wrapper): without #with_level a per-job `log_level` is
31
+ # ignored rather than failing the job it was meant to annotate.
32
+ @with_level = @logger.respond_to?(:with_level)
33
+ @skip = @config[:skip_default_job_logging] ? true : false
34
+ # Only consulted when `args` is a logged attribute — the one place this
35
+ # logger prints arguments. See Wurk::Redact.
36
+ @redact = @logged_attributes.any? { |attr, _| attr == 'args' } ? @config[:redact_args] : nil
28
37
  end
29
38
 
30
39
  def call(_item, _queue)
@@ -49,7 +58,7 @@ module Wurk
49
58
 
50
59
  level = job_hash['log_level']
51
60
  Wurk::Context.with(h) do
52
- if level
61
+ if level && @with_level
53
62
  @logger.with_level(level, &block)
54
63
  else
55
64
  yield
@@ -75,6 +84,7 @@ module Wurk
75
84
  @logged_attributes.each do |attr, sym|
76
85
  h[sym] = job_hash[attr] if job_hash.key?(attr)
77
86
  end
87
+ h[:args] = Redact.args(job_hash, @redact) if @redact && job_hash.key?('args')
78
88
  h
79
89
  end
80
90
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'base64'
4
4
  require 'zlib'
5
+ require_relative 'redact'
5
6
 
6
7
  module Wurk
7
8
  # One job payload viewed from the data API (Queue#each / JobSet#each).
@@ -22,8 +23,8 @@ module Wurk
22
23
  ActionMailer::Parameterized::DeliveryJob
23
24
  ActionMailer::MailDeliveryJob
24
25
  ].freeze
25
-
26
- attr_reader :queue
26
+ AJ_PREFIX = '_aj_'
27
+ AJ_GLOBALID = '_aj_globalid'
27
28
 
28
29
  # @param item [String, Hash] raw JSON payload or pre-parsed hash.
29
30
  # @param queue_name [String, nil] queue this record came from.
@@ -35,12 +36,21 @@ module Wurk
35
36
  @item = item
36
37
  @value = nil
37
38
  end
38
- @queue = queue_name || (@item && @item['queue'])
39
+ @queue = queue_name
39
40
  end
40
41
 
41
- # Lazily parsed payload. Memoized; never re-parses.
42
+ def queue = @queue || item['queue']
43
+
44
+ # Lazily parsed payload. Memoized; never re-parses. Invalid JSON reads as
45
+ # an empty hash whose raw bytes become `args`, as upstream does, so one
46
+ # corrupt payload can't break a dashboard page or a Queue#each walk.
42
47
  def item
43
- @item ||= Wurk.load_json(@value)
48
+ @item ||= begin
49
+ Wurk.load_json(@value)
50
+ rescue ::JSON::ParserError
51
+ @invalid_args = [@value]
52
+ {}
53
+ end
44
54
  end
45
55
 
46
56
  # Lazily serialized payload. When constructed from a Hash, we
@@ -50,10 +60,14 @@ module Wurk
50
60
  end
51
61
 
52
62
  def klass = item['class']
53
- def args = item['args']
54
63
  def jid = item['jid']
55
64
  def bid = item['bid']
56
65
 
66
+ def args
67
+ parsed = item
68
+ @invalid_args || parsed['args']
69
+ end
70
+
57
71
  # IterableJob progress for this job, or nil for a non-iterable job (no
58
72
  # `it-<jid>` HASH). Spec §19.3. Reads via the IterableJobQuery data API.
59
73
  def iterable_state
@@ -64,7 +78,9 @@ module Wurk
64
78
 
65
79
  def tags = item['tags'] || []
66
80
  def enqueued_at = parse_time(item['enqueued_at'])
67
- def created_at = parse_time(item['created_at'])
81
+ # Falls back to enqueued_at as upstream does; nil (not upstream's epoch 0)
82
+ # only when both are absent, so a dashboard shows "unknown", not 1970.
83
+ def created_at = parse_time(item['created_at'] || item['enqueued_at'])
68
84
  def failed_at = parse_time(item['failed_at'])
69
85
  def retried_at = parse_time(item['retried_at'])
70
86
 
@@ -86,13 +102,13 @@ module Wurk
86
102
  # current integer-ms `enqueued_at` shapes. Returns 0.0 when missing
87
103
  # or somehow in the future (clock skew).
88
104
  def latency
89
- JobRecord.latency_from(item['enqueued_at'])
105
+ JobRecord.latency_from(item['enqueued_at'] || item['created_at'])
90
106
  end
91
107
 
92
108
  # Removes exactly this payload's bytes from the queue list. Returns
93
109
  # true when LREM removed ≥1 entry. Idempotent. Method name is
94
110
  # Sidekiq wire-compat — renaming would break `JobRecord#delete`.
95
- def delete # rubocop:disable Naming/PredicateMethod
111
+ def delete
96
112
  removed = Wurk.redis { |c| c.call('LREM', Keys.queue(@queue), 1, value) }
97
113
  removed.to_i.positive?
98
114
  end
@@ -102,17 +118,25 @@ module Wurk
102
118
  def display_class
103
119
  return @display_class if defined?(@display_class)
104
120
 
105
- @display_class = active_job_wrapper? ? unwrap_class : klass
121
+ @display_class = item['display_class'] || (active_job_wrapper? ? unwrap_class : klass)
106
122
  end
107
123
 
108
124
  # UI-facing args. Encrypted jobs (§4.7) get their envelope last arg
109
- # masked as "<encrypted>" so ciphertext never reaches the dashboard;
125
+ # masked as "[encrypted data]" so ciphertext never reaches the dashboard;
110
126
  # redaction keys off the envelope shape, so it fires whether or not the
111
127
  # stored hash carried the `encrypt` flag. Cleartext preceding args stay
112
128
  # visible for triage. Display-only — the stored payload is untouched.
129
+ #
130
+ # A host `redact_args` hook (Wurk::Redact) replaces all of that: it sees
131
+ # the whole job and decides what is shown. This is the single choke point
132
+ # for every surface that renders a job — dashboard JSON, search, the /v1
133
+ # machine API, Sidekiq::Web-style views.
113
134
  def display_args
114
135
  return @display_args if defined?(@display_args)
115
136
 
137
+ hook = Wurk::Redact.hook
138
+ return @display_args = Wurk::Redact.args(item, hook) if hook
139
+
116
140
  base = active_job_wrapper? ? unwrap_args : args
117
141
  @display_args = Wurk::Encryption.redact_args('args' => base, 'encrypt' => item['encrypt'])
118
142
  end
@@ -151,21 +175,46 @@ module Wurk
151
175
  "#{mailer_args[0]}##{mailer_args[1]}"
152
176
  end
153
177
 
178
+ # ActiveJob serializes GlobalID args as {"_aj_globalid" => gid} and tags
179
+ # hashes with `_aj_*` bookkeeping keys; upstream shows the gid string and
180
+ # drops the bookkeeping. ActionMailer payloads lead with
181
+ # [mailer, method, "deliver_now"]; MailDeliveryJob's real args are the
182
+ # trailing {"params", "args"} hash.
154
183
  def unwrap_args
155
- job_args = args.dig(0, 'arguments') || []
156
- wrapped = item['wrapped']
157
- return job_args unless ACTION_MAILER_JOBS.include?(wrapped)
184
+ job_args = deserialize_aj(args.dig(0, 'arguments') || [])
185
+ case item['wrapped']
186
+ when *ACTION_MAILER_JOBS then mailer_args(item['wrapped'], job_args.drop(3))
187
+ else job_args
188
+ end
189
+ end
190
+
191
+ def mailer_args(wrapped, rest)
192
+ return rest unless wrapped == 'ActionMailer::MailDeliveryJob' && rest.first.is_a?(Hash)
193
+
194
+ rest.first.values_at('params', 'args')
195
+ end
196
+
197
+ def deserialize_aj(arg)
198
+ case arg
199
+ when Array then arg.map { |a| deserialize_aj(a) }
200
+ when Hash
201
+ return arg[AJ_GLOBALID] if arg.size == 1 && arg.key?(AJ_GLOBALID)
158
202
 
159
- # ActionMailer payloads start with [mailer, method, "deliver_now", *real_args]
160
- job_args.drop(3)
203
+ arg.reject { |k, _| k.start_with?(AJ_PREFIX) }.transform_values { |v| deserialize_aj(v) }
204
+ else arg
205
+ end
161
206
  end
162
207
 
163
- # Parse Sidekiq's mixed time formats (Float secs, Integer ms) into Time.
208
+ # Parse Sidekiq's mixed time formats (Float secs, Integer ms) into a UTC
209
+ # Time, whatever the branch, so callers never see the host's zone.
210
+ # Integer ms is split into whole seconds + ms rather than divided as a
211
+ # Float, which would round some timestamps a millisecond off.
164
212
  def parse_time(value)
165
213
  return nil if value.nil?
214
+ return ::Time.at(value).utc if value < 10_000_000_000
215
+ return ::Time.at(value / 1_000.0).utc unless value.is_a?(Integer)
166
216
 
167
- ms = value < 10_000_000_000 ? value * 1_000 : value
168
- ::Time.at(ms / 1_000.0)
217
+ ::Time.at(value / 1_000, value % 1_000, :millisecond).utc
169
218
  end
170
219
  end
171
220
  end
@@ -208,7 +208,9 @@ module Wurk
208
208
  # Caller branches on strategy; seconds is meaningful only for :default.
209
209
  def delay_for(jobinst, count, exception, msg)
210
210
  rv = run_retry_in_block(jobinst, count, exception, msg)
211
- rv = rv.to_i if rv.is_a?(::Float)
211
+ # `10.minutes * count` (a Duration), `"60"` and `30.0` all mean seconds;
212
+ # Symbols have no #to_i, so `:kill` / `:discard` pass through intact.
213
+ rv = rv.to_i if rv.respond_to?(:to_i)
212
214
  default_delay = (count**4) + 15
213
215
 
214
216
  case rv
@@ -224,8 +226,7 @@ module Wurk
224
226
  end
225
227
 
226
228
  def run_retry_in_block(jobinst, count, exception, msg)
227
- block = jobinst&.class&.sidekiq_retry_in_block
228
- block = wrapped_block(msg, :sidekiq_retry_in_block) || block if msg['wrapped']
229
+ block = msg['wrapped'] ? wrapped_block(msg, :sidekiq_retry_in_block) : jobinst&.class&.sidekiq_retry_in_block
229
230
  block&.call(count, exception, msg)
230
231
  rescue ::Exception => e # rubocop:disable Lint/RescueException
231
232
  handle_exception(e, context: "Failure scheduling retry via `sidekiq_retry_in` on #{jobinst&.class&.name}")
@@ -249,8 +250,11 @@ module Wurk
249
250
  end
250
251
 
251
252
  def run_exhausted_block(jobinst, msg, exception)
252
- block = jobinst&.class&.sidekiq_retries_exhausted_block
253
- block = wrapped_block(msg, :sidekiq_retries_exhausted_block) || block if msg['wrapped']
253
+ block = if msg['wrapped']
254
+ wrapped_block(msg, :sidekiq_retries_exhausted_block)
255
+ else
256
+ jobinst&.class&.sidekiq_retries_exhausted_block
257
+ end
254
258
  block&.call(msg, exception)
255
259
  rescue ::Exception => e # rubocop:disable Lint/RescueException
256
260
  handle_exception(e, context: 'Error calling retries_exhausted', job: msg)
@@ -258,7 +262,9 @@ module Wurk
258
262
  end
259
263
 
260
264
  # Wrappers (ActiveJob, custom) expose retry blocks on the wrapped class
261
- # via `msg["wrapped"]`. We look up the constant and prefer its block.
265
+ # via `msg["wrapped"]`. A wrapped job uses the wrapped class's block or
266
+ # none — never the wrapper's (Sidekiq's rule): the wrapper is shared by
267
+ # every wrapped class, so its block would apply to all of them.
262
268
  def wrapped_block(msg, attr)
263
269
  wrapped = ::Object.const_get(msg['wrapped'])
264
270
  wrapped.respond_to?(attr) ? wrapped.public_send(attr) : nil