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.
- checksums.yaml +4 -4
- data/README.md +7 -4
- data/app/controllers/concerns/wurk/sse_streaming.rb +2 -1
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +33 -9
- data/app/controllers/wurk/api/pagination.rb +25 -7
- data/app/controllers/wurk/api_controller.rb +49 -25
- data/app/controllers/wurk/extensions_controller.rb +8 -2
- data/app/controllers/wurk/profiles_controller.rb +45 -9
- data/config/routes.rb +31 -10
- data/lib/generators/wurk/install/templates/wurk.rb +1 -1
- data/lib/sidekiq/capsule.rb +4 -0
- data/lib/sidekiq/component.rb +4 -0
- data/lib/sidekiq/config.rb +4 -0
- data/lib/sidekiq/deploy.rb +4 -0
- data/lib/sidekiq/embedded.rb +4 -0
- data/lib/sidekiq/fetch.rb +4 -0
- data/lib/sidekiq/iterable_job.rb +4 -0
- data/lib/sidekiq/job/interrupt_handler.rb +4 -0
- data/lib/sidekiq/job/iterable/active_record_enumerator.rb +4 -0
- data/lib/sidekiq/job/iterable/csv_enumerator.rb +4 -0
- data/lib/sidekiq/job/iterable.rb +4 -0
- data/lib/sidekiq/job_logger.rb +4 -0
- data/lib/sidekiq/job_util.rb +4 -0
- data/lib/sidekiq/loader.rb +4 -0
- data/lib/sidekiq/logger.rb +4 -0
- data/lib/sidekiq/metrics/query.rb +4 -0
- data/lib/sidekiq/middleware/current_attributes.rb +7 -0
- data/lib/sidekiq/middleware/i18n.rb +6 -0
- data/lib/sidekiq/middleware/modules.rb +4 -0
- data/lib/sidekiq/pro/web.rb +5 -0
- data/lib/sidekiq/profiler.rb +4 -0
- data/lib/sidekiq/redis_client_adapter.rb +4 -0
- data/lib/sidekiq/test_api.rb +5 -0
- data/lib/sidekiq/testing/inline.rb +8 -0
- data/lib/sidekiq/transaction_aware_client.rb +4 -0
- data/lib/sidekiq/worker_compatibility_alias.rb +4 -0
- data/lib/sidekiq-ent/periodic/testing.rb +5 -0
- data/lib/sidekiq-ent/web.rb +5 -0
- data/lib/sidekiq-ent.rb +7 -0
- data/lib/sidekiq-pro.rb +7 -0
- data/lib/wurk/api/fast.rb +8 -33
- data/lib/wurk/api/idempotency.rb +28 -5
- data/lib/wurk/api/swarm.rb +1 -1
- data/lib/wurk/batch/callbacks.rb +143 -92
- data/lib/wurk/batch/death_handler.rb +19 -8
- data/lib/wurk/batch/server_middleware.rb +37 -24
- data/lib/wurk/batch/status.rb +49 -15
- data/lib/wurk/batch.rb +164 -65
- data/lib/wurk/capsule.rb +0 -2
- data/lib/wurk/client/buffered.rb +227 -106
- data/lib/wurk/client.rb +33 -13
- data/lib/wurk/compat.rb +13 -0
- data/lib/wurk/configuration.rb +155 -24
- data/lib/wurk/cpu_count.rb +97 -0
- data/lib/wurk/cron.rb +97 -34
- data/lib/wurk/dead_set.rb +6 -7
- data/lib/wurk/encryption.rb +46 -13
- data/lib/wurk/fetcher/many_queues.rb +65 -0
- data/lib/wurk/fetcher/orphan_grace.rb +67 -0
- data/lib/wurk/fetcher/private_list_key.rb +76 -0
- data/lib/wurk/fetcher/reaper.rb +55 -74
- data/lib/wurk/fetcher/reliable.rb +23 -17
- data/lib/wurk/fetcher/unit_of_work.rb +19 -1
- data/lib/wurk/fetcher/unparseable_keys.rb +56 -0
- data/lib/wurk/flow/completion.rb +28 -3
- data/lib/wurk/flow/creation.rb +24 -4
- data/lib/wurk/flow.rb +24 -2
- data/lib/wurk/health.rb +182 -40
- data/lib/wurk/heartbeat.rb +37 -14
- data/lib/wurk/history.rb +7 -1
- data/lib/wurk/import/sidekiq_cron.rb +248 -0
- data/lib/wurk/job/options.rb +22 -3
- data/lib/wurk/job_logger.rb +13 -3
- data/lib/wurk/job_record.rb +68 -19
- data/lib/wurk/job_retry.rb +12 -6
- data/lib/wurk/job_set.rb +99 -51
- data/lib/wurk/job_util.rb +27 -8
- data/lib/wurk/launcher.rb +84 -40
- data/lib/wurk/leader.rb +53 -28
- data/lib/wurk/limiter/base.rb +11 -30
- data/lib/wurk/limiter/bucket.rb +14 -5
- data/lib/wurk/limiter/server_middleware.rb +2 -1
- data/lib/wurk/limiter.rb +1 -1
- data/lib/wurk/loader.rb +48 -0
- data/lib/wurk/lua/batch_ack_complete.lua +28 -0
- data/lib/wurk/lua/batch_ack_failed.lua +18 -0
- data/lib/wurk/lua/batch_ack_success.lua +35 -0
- data/lib/wurk/lua/batch_append_callback.lua +66 -0
- data/lib/wurk/lua/batch_delete.lua +31 -0
- data/lib/wurk/lua/batch_hold.lua +17 -0
- data/lib/wurk/lua/batch_invalidate.lua +14 -0
- data/lib/wurk/lua/batch_kid_done.lua +14 -0
- data/lib/wurk/lua/batch_push.lua +47 -0
- data/lib/wurk/lua/batch_remove_jobs.lua +29 -0
- data/lib/wurk/lua/batch_schedule.lua +27 -0
- data/lib/wurk/lua/fetch_first.lua +27 -0
- data/lib/wurk/lua/flow_abandon.lua +16 -21
- data/lib/wurk/lua/flow_advance.lua +41 -26
- data/lib/wurk/lua/flow_create.lua +24 -9
- data/lib/wurk/lua/flow_fail.lua +3 -3
- data/lib/wurk/lua/leader_campaign.lua +19 -0
- data/lib/wurk/lua/limiter_bucket_acquire.lua +3 -2
- data/lib/wurk/lua/limiter_list_sweep.lua +8 -9
- data/lib/wurk/lua/loader.rb +36 -23
- data/lib/wurk/lua/throttle_slot.lua +13 -26
- data/lib/wurk/lua.rb +31 -244
- data/lib/wurk/manager.rb +25 -18
- data/lib/wurk/metrics/accumulator.rb +31 -10
- data/lib/wurk/metrics/dashboard_series.rb +163 -0
- data/lib/wurk/metrics/histogram.rb +46 -0
- data/lib/wurk/metrics/history.rb +49 -57
- data/lib/wurk/metrics/prometheus.rb +232 -0
- data/lib/wurk/metrics/query.rb +127 -155
- data/lib/wurk/metrics/rollup.rb +2 -2
- data/lib/wurk/metrics/statsd.rb +1 -1
- data/lib/wurk/middleware/interrupt_handler.rb +6 -2
- data/lib/wurk/middleware/poison_pill.rb +5 -4
- data/lib/wurk/middleware/timeout.rb +8 -8
- data/lib/wurk/process_set.rb +5 -5
- data/lib/wurk/processor.rb +69 -31
- data/lib/wurk/profile_set.rb +19 -24
- data/lib/wurk/profiler.rb +48 -50
- data/lib/wurk/queue.rb +36 -19
- data/lib/wurk/rails_boot.rb +33 -1
- data/lib/wurk/railtie.rb +2 -0
- data/lib/wurk/rake_tasks.rb +18 -0
- data/lib/wurk/redact.rb +39 -0
- data/lib/wurk/redis_connection.rb +6 -3
- data/lib/wurk/redis_options.rb +21 -4
- data/lib/wurk/redis_pool.rb +119 -21
- data/lib/wurk/scheduled.rb +44 -11
- data/lib/wurk/sentry/error_handler.rb +53 -7
- data/lib/wurk/sentry/middleware.rb +7 -8
- data/lib/wurk/shutdown_gate.rb +24 -1
- data/lib/wurk/sorted_entry.rb +16 -5
- data/lib/wurk/stats.rb +9 -4
- data/lib/wurk/status.rb +2 -2
- data/lib/wurk/swarm/child_boot.rb +48 -38
- data/lib/wurk/swarm/liveness.rb +153 -0
- data/lib/wurk/swarm/restart.rb +24 -3
- data/lib/wurk/swarm.rb +115 -24
- data/lib/wurk/telemetry/server_middleware.rb +2 -5
- data/lib/wurk/testing.rb +15 -5
- data/lib/wurk/throttle.rb +16 -1
- data/lib/wurk/topology.rb +1 -5
- data/lib/wurk/unique.rb +1 -1
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/watchdog.rb +2 -2
- data/lib/wurk/web/config.rb +56 -20
- data/lib/wurk/web/enterprise.rb +21 -18
- data/lib/wurk/web/extension.rb +98 -22
- data/lib/wurk/web/rack_app.rb +12 -11
- data/lib/wurk/web/search.rb +18 -8
- data/lib/wurk/worker/setter.rb +17 -14
- data/lib/wurk/worker.rb +6 -67
- data/lib/wurk.rb +2 -3
- data/vendor/assets/dashboard/assets/ArgsValue-DsPb_KZ-.js +1 -0
- data/vendor/assets/dashboard/assets/BatchDetail-BCIXJOpZ.js +1 -0
- data/vendor/assets/dashboard/assets/Batches-IjaQfXW6.js +1 -0
- data/vendor/assets/dashboard/assets/Busy-CaQrf1Ve.js +1 -0
- data/vendor/assets/dashboard/assets/Cron-B18EHn_Y.js +1 -0
- data/vendor/assets/dashboard/assets/Dashboard-DdT5ZaUg.js +1 -0
- data/vendor/assets/dashboard/assets/Dead-DWIy-Mu8.js +1 -0
- data/vendor/assets/dashboard/assets/Extension-BjBUWhHf.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-DwH3PXXt.js +1 -0
- data/vendor/assets/dashboard/assets/FlowDetail-DnexL72n.js +1 -0
- data/vendor/assets/dashboard/assets/FlowState-Cr4aX6-d.js +1 -0
- data/vendor/assets/dashboard/assets/Flows-DO6EimbZ.js +1 -0
- data/vendor/assets/dashboard/assets/JobDetailModal-aPjgq-U9.js +2 -0
- data/vendor/assets/dashboard/assets/Limiters-DxpwWCNe.js +1 -0
- data/vendor/assets/dashboard/assets/Metrics-C3JL1GIH.js +1 -0
- data/vendor/assets/dashboard/assets/NotFound-BfP9uAV9.js +1 -0
- data/vendor/assets/dashboard/assets/PageHeader-DbC97afG.js +1 -0
- data/vendor/assets/dashboard/assets/Profiles-BcaHreMe.js +1 -0
- data/vendor/assets/dashboard/assets/Queues-ZlBc5tyD.js +1 -0
- data/vendor/assets/dashboard/assets/Retries-Bg3nkeiL.js +1 -0
- data/vendor/assets/dashboard/assets/Scheduled-BrMZPZGl.js +1 -0
- data/vendor/assets/dashboard/assets/Search-B5X5pGht.js +1 -0
- data/vendor/assets/dashboard/assets/charts-uDyf4PN0.js +1 -0
- data/vendor/assets/dashboard/assets/i18n-CJeYfeVt.js +1 -0
- data/vendor/assets/dashboard/assets/index-B36QCAf-.css +1 -0
- data/vendor/assets/dashboard/assets/index-DQujgZgH.js +141 -0
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-O3-5rMk3.js +1 -0
- data/vendor/assets/dashboard/assets/useSort-xfnwjQAu.js +1 -0
- data/vendor/assets/dashboard/assets/utils-BIKHwNKB.js +1 -0
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +90 -34
- data/config/locales/en.yml +0 -15
- data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +0 -1
- data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +0 -1
- data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +0 -1
- data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +0 -1
- data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +0 -1
- data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +0 -1
- data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +0 -1
- data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +0 -1
- data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +0 -1
- data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +0 -1
- data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +0 -2
- data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +0 -1
- data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +0 -1
- data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +0 -1
- data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +0 -1
- data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +0 -1
- data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +0 -1
- data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +0 -1
- data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +0 -1
- data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +0 -141
- data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +0 -1
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +0 -1
- data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +0 -1
- 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
|
data/lib/wurk/job/options.rb
CHANGED
|
@@ -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
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
data/lib/wurk/job_logger.rb
CHANGED
|
@@ -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#
|
|
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
|
-
|
|
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
|
data/lib/wurk/job_record.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
39
|
+
@queue = queue_name
|
|
39
40
|
end
|
|
40
41
|
|
|
41
|
-
|
|
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 ||=
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/wurk/job_retry.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
253
|
-
|
|
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"]`.
|
|
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
|