wurk 0.0.1
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 +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/SECURITY.md +39 -0
- data/app/controllers/wurk/api/pagination.rb +67 -0
- data/app/controllers/wurk/api/serializers.rb +131 -0
- data/app/controllers/wurk/api_controller.rb +248 -0
- data/app/controllers/wurk/application_controller.rb +7 -0
- data/app/controllers/wurk/dashboard_controller.rb +48 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +34 -0
- data/exe/wurk +22 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
- data/lib/generators/wurk/install/install_generator.rb +22 -0
- data/lib/generators/wurk/install/templates/wurk.rb +16 -0
- data/lib/wurk/active_job/wrapper.rb +32 -0
- data/lib/wurk/api/fast.rb +78 -0
- data/lib/wurk/batch/buffer.rb +26 -0
- data/lib/wurk/batch/callback_job.rb +37 -0
- data/lib/wurk/batch/callbacks.rb +176 -0
- data/lib/wurk/batch/client_middleware.rb +27 -0
- data/lib/wurk/batch/death_handler.rb +39 -0
- data/lib/wurk/batch/empty.rb +21 -0
- data/lib/wurk/batch/server_middleware.rb +62 -0
- data/lib/wurk/batch/status.rb +140 -0
- data/lib/wurk/batch.rb +351 -0
- data/lib/wurk/batch_set.rb +67 -0
- data/lib/wurk/capsule.rb +176 -0
- data/lib/wurk/cli.rb +349 -0
- data/lib/wurk/client/buffered.rb +372 -0
- data/lib/wurk/client.rb +330 -0
- data/lib/wurk/compat.rb +136 -0
- data/lib/wurk/component.rb +136 -0
- data/lib/wurk/configuration.rb +373 -0
- data/lib/wurk/context.rb +35 -0
- data/lib/wurk/cron.rb +636 -0
- data/lib/wurk/dashboard_manifest.rb +39 -0
- data/lib/wurk/dead_set.rb +78 -0
- data/lib/wurk/deploy.rb +91 -0
- data/lib/wurk/embedded.rb +94 -0
- data/lib/wurk/encryption.rb +276 -0
- data/lib/wurk/engine.rb +81 -0
- data/lib/wurk/fetcher/reaper.rb +264 -0
- data/lib/wurk/fetcher/reliable.rb +138 -0
- data/lib/wurk/fetcher.rb +11 -0
- data/lib/wurk/health.rb +193 -0
- data/lib/wurk/heartbeat.rb +211 -0
- data/lib/wurk/iterable_job.rb +292 -0
- data/lib/wurk/job/options.rb +70 -0
- data/lib/wurk/job.rb +33 -0
- data/lib/wurk/job_logger.rb +68 -0
- data/lib/wurk/job_record.rb +156 -0
- data/lib/wurk/job_retry.rb +320 -0
- data/lib/wurk/job_set.rb +212 -0
- data/lib/wurk/job_util.rb +162 -0
- data/lib/wurk/keys.rb +52 -0
- data/lib/wurk/launcher.rb +289 -0
- data/lib/wurk/leader.rb +221 -0
- data/lib/wurk/limiter/base.rb +138 -0
- data/lib/wurk/limiter/bucket.rb +80 -0
- data/lib/wurk/limiter/concurrent.rb +132 -0
- data/lib/wurk/limiter/leaky.rb +91 -0
- data/lib/wurk/limiter/points.rb +89 -0
- data/lib/wurk/limiter/server_middleware.rb +77 -0
- data/lib/wurk/limiter/unlimited.rb +48 -0
- data/lib/wurk/limiter/window.rb +80 -0
- data/lib/wurk/limiter.rb +255 -0
- data/lib/wurk/logger.rb +81 -0
- data/lib/wurk/lua/loader.rb +53 -0
- data/lib/wurk/lua.rb +187 -0
- data/lib/wurk/manager.rb +132 -0
- data/lib/wurk/metrics/history.rb +151 -0
- data/lib/wurk/metrics/query.rb +173 -0
- data/lib/wurk/metrics/rollup.rb +169 -0
- data/lib/wurk/metrics/statsd.rb +197 -0
- data/lib/wurk/metrics.rb +7 -0
- data/lib/wurk/middleware/chain.rb +128 -0
- data/lib/wurk/middleware/current_attributes.rb +87 -0
- data/lib/wurk/middleware/expiry.rb +50 -0
- data/lib/wurk/middleware/i18n.rb +63 -0
- data/lib/wurk/middleware/interrupt_handler.rb +45 -0
- data/lib/wurk/middleware/poison_pill.rb +149 -0
- data/lib/wurk/middleware.rb +34 -0
- data/lib/wurk/process_set.rb +243 -0
- data/lib/wurk/processor.rb +247 -0
- data/lib/wurk/queue.rb +108 -0
- data/lib/wurk/queues.rb +80 -0
- data/lib/wurk/rails.rb +9 -0
- data/lib/wurk/railtie.rb +28 -0
- data/lib/wurk/redis_pool.rb +79 -0
- data/lib/wurk/retry_set.rb +17 -0
- data/lib/wurk/scheduled.rb +189 -0
- data/lib/wurk/scheduled_set.rb +18 -0
- data/lib/wurk/sorted_entry.rb +95 -0
- data/lib/wurk/stats.rb +190 -0
- data/lib/wurk/swarm/child_boot.rb +105 -0
- data/lib/wurk/swarm.rb +260 -0
- data/lib/wurk/testing.rb +102 -0
- data/lib/wurk/topology.rb +74 -0
- data/lib/wurk/unique.rb +240 -0
- data/lib/wurk/version.rb +5 -0
- data/lib/wurk/web/config.rb +180 -0
- data/lib/wurk/web/enterprise.rb +138 -0
- data/lib/wurk/web/search.rb +139 -0
- data/lib/wurk/web.rb +25 -0
- data/lib/wurk/work_set.rb +116 -0
- data/lib/wurk/worker/setter.rb +93 -0
- data/lib/wurk/worker.rb +216 -0
- data/lib/wurk.rb +238 -0
- data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
- data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
- data/vendor/assets/dashboard/index.html +13 -0
- data/vendor/assets/dashboard/wurk-manifest.json +4 -0
- metadata +232 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../component'
|
|
4
|
+
require_relative 'history'
|
|
5
|
+
|
|
6
|
+
module Wurk
|
|
7
|
+
module Metrics
|
|
8
|
+
# Leader-only background thread that rolls the per-class minute buckets
|
|
9
|
+
# written by Wurk::Metrics::History (`j|YYMMDD|H:M`) up into compact,
|
|
10
|
+
# cluster-total time-series buckets the dashboard "throughput" / "failures"
|
|
11
|
+
# charts read directly:
|
|
12
|
+
#
|
|
13
|
+
# jr|1m|<epoch> HASH {p,f,ms} TTL 24h (1-minute resolution)
|
|
14
|
+
# jr|5m|<epoch> HASH {p,f,ms} TTL 7d (5-minute resolution)
|
|
15
|
+
# jr|1h|<epoch> HASH {p,f,ms} TTL 30d (1-hour resolution)
|
|
16
|
+
#
|
|
17
|
+
# `<epoch>` is the UTC start-of-bucket as integer seconds. Totals are summed
|
|
18
|
+
# across every job class, so a 30-day chart reads ~720 small hashes instead
|
|
19
|
+
# of fanning out over ~43k per-class minute keys.
|
|
20
|
+
#
|
|
21
|
+
# Every write is an idempotent HSET. Each tick recomputes the trailing few
|
|
22
|
+
# 1m buckets from the source minute hash, then recomputes the coarse buckets
|
|
23
|
+
# from their 1m children. Re-running a tick (a missed tick, a leadership
|
|
24
|
+
# change, a late metric write) converges to the same totals — it never
|
|
25
|
+
# double-counts. Storage is bounded purely by the per-bucket TTLs; see
|
|
26
|
+
# docs/metrics-history.md for the retention math.
|
|
27
|
+
class Rollup
|
|
28
|
+
include Component
|
|
29
|
+
|
|
30
|
+
PREFIX = 'jr'
|
|
31
|
+
|
|
32
|
+
# bucket => [step_seconds, ttl_seconds]. The retention is the issue's
|
|
33
|
+
# spec: 1m kept 24h, 5m kept 7d, 1h kept 30d.
|
|
34
|
+
BUCKETS = {
|
|
35
|
+
'1m' => [60, 24 * 60 * 60],
|
|
36
|
+
'5m' => [300, 7 * 24 * 60 * 60],
|
|
37
|
+
'1h' => [3600, 30 * 24 * 60 * 60]
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
COARSE = %w[5m 1h].freeze
|
|
41
|
+
|
|
42
|
+
DEFAULT_TICK_SECONDS = 60
|
|
43
|
+
# Re-roll the last N completed minutes from source on every tick
|
|
44
|
+
# (idempotent). This self-heals a leadership failover / restart or a late
|
|
45
|
+
# metric write up to N minutes old — the source `j|…` buckets live 3 days,
|
|
46
|
+
# so re-reading them folds the gap back in. Only outages longer than this
|
|
47
|
+
# leave a hole that ages out with the bucket TTL (best-effort metrics).
|
|
48
|
+
LOOKBACK_MINUTES = 15
|
|
49
|
+
|
|
50
|
+
def self.bucket_key(bucket, epoch)
|
|
51
|
+
"#{PREFIX}|#{bucket}|#{epoch}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(config)
|
|
55
|
+
@config = config
|
|
56
|
+
@done = false
|
|
57
|
+
@mutex = ::Mutex.new
|
|
58
|
+
@sleeper = ::ConditionVariable.new
|
|
59
|
+
@tick_interval = config[:metrics_rollup_interval] || DEFAULT_TICK_SECONDS
|
|
60
|
+
@thread = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def start
|
|
64
|
+
@thread ||= safe_thread('metrics-rollup') do # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
65
|
+
wait
|
|
66
|
+
until @done
|
|
67
|
+
tick
|
|
68
|
+
wait
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def terminate
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
@done = true
|
|
76
|
+
@sleeper.signal
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Leader-gated: only the elected leader writes the cluster-total series,
|
|
81
|
+
# so N workers don't each HSET the same buckets every minute.
|
|
82
|
+
def tick(now: ::Time.now)
|
|
83
|
+
return unless leader?
|
|
84
|
+
|
|
85
|
+
roll(now)
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
handle_exception(e, { context: 'metrics-rollup' })
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One rollup pass, bypassing the leader gate and the sleep loop. Public so
|
|
91
|
+
# deterministic specs and a manual "roll now" can drive it directly.
|
|
92
|
+
def roll(now = ::Time.now)
|
|
93
|
+
cur_min = floor_min(now)
|
|
94
|
+
minutes = (1..LOOKBACK_MINUTES).map { |i| cur_min - (i * 60) }
|
|
95
|
+
minutes.each { |epoch_min| write_minute_bucket(epoch_min) }
|
|
96
|
+
recompute_coarse(minutes)
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def write_minute_bucket(epoch_min)
|
|
103
|
+
store('1m', epoch_min, minute_total(epoch_min))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Sum every class's p/f/ms in the source minute hash into a single total.
|
|
107
|
+
def minute_total(epoch_min)
|
|
108
|
+
raw = redis { |c| c.call('HGETALL', source_minute_key(epoch_min)) }
|
|
109
|
+
raw = raw.each_slice(2).to_h if raw.is_a?(::Array)
|
|
110
|
+
total = { p: 0, f: 0, ms: 0 }
|
|
111
|
+
raw.each do |field, value|
|
|
112
|
+
_klass, kind = field.split('|', 2)
|
|
113
|
+
total[kind.to_sym] += value.to_i if kind && total.key?(kind.to_sym)
|
|
114
|
+
end
|
|
115
|
+
total
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Re-sum each coarse bucket from its 1m children, for every window the
|
|
119
|
+
# just-written minutes touched (covers the current window plus any
|
|
120
|
+
# boundary the lookback crossed).
|
|
121
|
+
def recompute_coarse(minutes)
|
|
122
|
+
COARSE.each do |bucket|
|
|
123
|
+
step, = BUCKETS[bucket]
|
|
124
|
+
minutes.map { |m| (m / step) * step }.uniq.each { |w| store(bucket, w, child_total(w, step)) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def child_total(window_start, step)
|
|
129
|
+
keys = (window_start...(window_start + step)).step(60).map { |s| self.class.bucket_key('1m', s) }
|
|
130
|
+
sum_pfms(redis { |c| c.pipelined { |p| keys.each { |k| p.call('HMGET', k, 'p', 'f', 'ms') } } })
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def sum_pfms(rows)
|
|
134
|
+
rows.each_with_object({ p: 0, f: 0, ms: 0 }) do |(p, f, ms), total|
|
|
135
|
+
total[:p] += p.to_i
|
|
136
|
+
total[:f] += f.to_i
|
|
137
|
+
total[:ms] += ms.to_i
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Skip empty buckets so an idle cluster doesn't litter Redis with zero
|
|
142
|
+
# rows; a missing bucket reads back as zero on the query side anyway.
|
|
143
|
+
def store(bucket, epoch, total)
|
|
144
|
+
return if total.values.all?(&:zero?)
|
|
145
|
+
|
|
146
|
+
_step, ttl = BUCKETS[bucket]
|
|
147
|
+
key = self.class.bucket_key(bucket, epoch)
|
|
148
|
+
redis do |c|
|
|
149
|
+
c.call('HSET', key, 'p', total[:p], 'f', total[:f], 'ms', total[:ms])
|
|
150
|
+
c.call('EXPIRE', key, ttl)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def source_minute_key(epoch_min)
|
|
155
|
+
Wurk::Metrics::History.minute_key(::Time.at(epoch_min).utc)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def floor_min(time)
|
|
159
|
+
(time.to_i / 60) * 60
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def wait
|
|
163
|
+
@mutex.synchronize do
|
|
164
|
+
@sleeper.wait(@mutex, @tick_interval) unless @done
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
module Metrics
|
|
5
|
+
# Pro parity (§9): emits per-job timing + counters to a statsd / dogstatsd
|
|
6
|
+
# client. The client itself is plumbed in by the host app via:
|
|
7
|
+
#
|
|
8
|
+
# Wurk.configure_server do |config|
|
|
9
|
+
# config.dogstatsd = -> { Datadog::Statsd.new('metrics.example.com', 8125) }
|
|
10
|
+
# config.server_middleware { |chain| chain.add Wurk::Metrics::Statsd }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The `dogstatsd` accessor is a *callable* — invoked once per process,
|
|
14
|
+
# memoized — so the client is built lazily AFTER fork. Sharing a UDP
|
|
15
|
+
# socket across forks is fine, but `Datadog::Statsd` keeps thread-locals
|
|
16
|
+
# that must be initialized inside the child.
|
|
17
|
+
#
|
|
18
|
+
# Per-job tuning via `Statsd.options = ->(klass, job, queue) { {tags:, sample_rate:} }`.
|
|
19
|
+
# Default options: tags `["worker:<klass>", "queue:<q>"]`, sample_rate 1.0.
|
|
20
|
+
# The `dd_rate` job option, when present, overrides sample_rate.
|
|
21
|
+
#
|
|
22
|
+
# Metric naming follows Sidekiq Pro 8+: every metric prefixed `sidekiq.`
|
|
23
|
+
# (the prefix is hardcoded, not configurable — third-party dashboards
|
|
24
|
+
# built for Sidekiq Pro work unchanged).
|
|
25
|
+
#
|
|
26
|
+
# `Statsd.increment(metric, tags:)` is the class-level fast path used by
|
|
27
|
+
# other Wurk components (Buffered client, Expiry middleware, super_fetch
|
|
28
|
+
# recovery, Batch lifecycle). No-op when no client is configured so
|
|
29
|
+
# callers never have to guard.
|
|
30
|
+
#
|
|
31
|
+
# Spec: docs/target/sidekiq-pro.md §9.
|
|
32
|
+
class Statsd
|
|
33
|
+
include Wurk::Middleware::ServerMiddleware
|
|
34
|
+
|
|
35
|
+
METRIC_PREFIX = 'sidekiq.'
|
|
36
|
+
DEFAULT_SAMPLE_RATE = 1.0
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
attr_accessor :options
|
|
40
|
+
|
|
41
|
+
# Counter shortcut used across the codebase. Tags are forwarded as
|
|
42
|
+
# given — caller's job to namespace them (`"class:Foo"`, `"queue:bar"`).
|
|
43
|
+
# No-op when no client is wired up.
|
|
44
|
+
def increment(metric, tags: nil, sample_rate: DEFAULT_SAMPLE_RATE)
|
|
45
|
+
client = self.client
|
|
46
|
+
return nil unless client
|
|
47
|
+
|
|
48
|
+
opts = sample_rate_kw(sample_rate)
|
|
49
|
+
opts[:tags] = tags if tags
|
|
50
|
+
client.increment("#{METRIC_PREFIX}#{metric}", **opts)
|
|
51
|
+
nil
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
handle_error(e)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def gauge(metric, value, tags: nil, sample_rate: DEFAULT_SAMPLE_RATE)
|
|
58
|
+
client = self.client
|
|
59
|
+
return nil unless client
|
|
60
|
+
|
|
61
|
+
opts = sample_rate_kw(sample_rate)
|
|
62
|
+
opts[:tags] = tags if tags
|
|
63
|
+
client.gauge("#{METRIC_PREFIX}#{metric}", value, **opts)
|
|
64
|
+
nil
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
handle_error(e)
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Distribution send. Some statsd clients lack `distribution` (vanilla
|
|
71
|
+
# statsd-ruby, for example) — fall back to `histogram` so the metric
|
|
72
|
+
# still lands somewhere. `dogstatsd-ruby` always has `distribution`.
|
|
73
|
+
def distribution(metric, value, tags: nil, sample_rate: DEFAULT_SAMPLE_RATE)
|
|
74
|
+
client = self.client
|
|
75
|
+
return nil unless client
|
|
76
|
+
|
|
77
|
+
opts = sample_rate_kw(sample_rate)
|
|
78
|
+
opts[:tags] = tags if tags
|
|
79
|
+
name = "#{METRIC_PREFIX}#{metric}"
|
|
80
|
+
if client.respond_to?(:distribution)
|
|
81
|
+
client.distribution(name, value, **opts)
|
|
82
|
+
elsif client.respond_to?(:histogram)
|
|
83
|
+
client.histogram(name, value, **opts)
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
handle_error(e)
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Resolves the live client: invokes the configured `dogstatsd` proc
|
|
92
|
+
# exactly once per process and memoizes. Returns nil when no proc
|
|
93
|
+
# is configured, so callers get a clean no-op without raising.
|
|
94
|
+
def client
|
|
95
|
+
return @client if defined?(@client) && !@client.nil?
|
|
96
|
+
|
|
97
|
+
builder = Wurk.configuration.respond_to?(:dogstatsd) ? Wurk.configuration.dogstatsd : nil
|
|
98
|
+
return nil if builder.nil?
|
|
99
|
+
|
|
100
|
+
@client = builder.respond_to?(:call) ? builder.call : builder
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Test/lifecycle hook. Reset between specs and after fork so the
|
|
104
|
+
# parent's socket doesn't bleed into children.
|
|
105
|
+
def reset!
|
|
106
|
+
@client = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def sample_rate_kw(rate)
|
|
112
|
+
rate == DEFAULT_SAMPLE_RATE ? {} : { sample_rate: rate }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def handle_error(err)
|
|
116
|
+
Wurk.configuration.handle_exception(err, context: 'Wurk::Metrics::Statsd')
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def call(_worker, job, queue) # rubocop:disable Metrics/AbcSize
|
|
121
|
+
client = safe_client
|
|
122
|
+
return yield if client.nil?
|
|
123
|
+
|
|
124
|
+
klass = job['class']
|
|
125
|
+
opts = per_job_options(klass, job, queue)
|
|
126
|
+
tags = opts[:tags]
|
|
127
|
+
rate = opts.fetch(:sample_rate, DEFAULT_SAMPLE_RATE)
|
|
128
|
+
|
|
129
|
+
emit(:increment, 'jobs.count', tags: tags, sample_rate: rate)
|
|
130
|
+
started = monotonic_ms
|
|
131
|
+
success = false
|
|
132
|
+
begin
|
|
133
|
+
yield
|
|
134
|
+
success = true
|
|
135
|
+
ensure
|
|
136
|
+
duration = monotonic_ms - started
|
|
137
|
+
# Metrics are best-effort: an emit failure mid-finalize must not
|
|
138
|
+
# corrupt the job result the caller already produced.
|
|
139
|
+
begin
|
|
140
|
+
finalize(success, duration, tags: tags, sample_rate: rate)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
self.class.send(:handle_error, e)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Wraps `self.class.client` so a misconfigured builder proc never turns
|
|
150
|
+
# a metrics-init failure into a job failure. Returns nil on error (the
|
|
151
|
+
# caller falls through to a plain `yield`).
|
|
152
|
+
def safe_client
|
|
153
|
+
self.class.client
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
self.class.send(:handle_error, e)
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Per-spec §9.2: caller-supplied proc may override tags / sample_rate
|
|
160
|
+
# on a per-job basis. The job's own `dd_rate` field, when present,
|
|
161
|
+
# always wins — it's the per-push override hinted in §8.
|
|
162
|
+
def per_job_options(klass, job, queue)
|
|
163
|
+
base = { tags: default_tags(klass, queue), sample_rate: DEFAULT_SAMPLE_RATE }
|
|
164
|
+
proc = self.class.options
|
|
165
|
+
if proc.respond_to?(:call)
|
|
166
|
+
custom = proc.call(klass, job, queue)
|
|
167
|
+
base = base.merge(custom) if custom.is_a?(Hash)
|
|
168
|
+
end
|
|
169
|
+
base[:sample_rate] = job['dd_rate'].to_f if job.key?('dd_rate')
|
|
170
|
+
base
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def default_tags(klass, queue)
|
|
174
|
+
["worker:#{klass}", "queue:#{queue}"]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def finalize(success, duration, tags:, sample_rate:)
|
|
178
|
+
metric = success ? 'jobs.success' : 'jobs.failure'
|
|
179
|
+
emit(:increment, metric, tags: tags, sample_rate: sample_rate)
|
|
180
|
+
emit(:gauge, 'jobs.perform', duration, tags: tags, sample_rate: sample_rate)
|
|
181
|
+
emit(:distribution, 'jobs.perform_dist', duration, tags: tags, sample_rate: sample_rate)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def emit(kind, metric, value = nil, tags:, sample_rate:)
|
|
185
|
+
case kind
|
|
186
|
+
when :increment then self.class.increment(metric, tags: tags, sample_rate: sample_rate)
|
|
187
|
+
when :gauge then self.class.gauge(metric, value, tags: tags, sample_rate: sample_rate)
|
|
188
|
+
when :distribution then self.class.distribution(metric, value, tags: tags, sample_rate: sample_rate)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def monotonic_ms
|
|
193
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
data/lib/wurk/metrics.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
module Middleware
|
|
5
|
+
# Ordered list of middleware bound to a config/capsule. Sidekiq contract:
|
|
6
|
+
# `add` appends (removing any existing entry for the same klass), `prepend`
|
|
7
|
+
# pushes to index 0, `insert_before`/`insert_after` anchor relative to an
|
|
8
|
+
# existing entry, `invoke` walks the chain handing the inner block to each.
|
|
9
|
+
#
|
|
10
|
+
# `retrieve` instantiates a fresh instance per entry on every job — entries
|
|
11
|
+
# store klass + ctor args, not the live object. This keeps middleware
|
|
12
|
+
# thread-safe by construction and matches Sidekiq's lifecycle.
|
|
13
|
+
#
|
|
14
|
+
# Spec: docs/target/sidekiq-free.md §10.1.
|
|
15
|
+
class Chain
|
|
16
|
+
include Enumerable
|
|
17
|
+
|
|
18
|
+
attr_reader :entries
|
|
19
|
+
attr_accessor :config
|
|
20
|
+
|
|
21
|
+
def initialize(config = nil)
|
|
22
|
+
@config = config
|
|
23
|
+
@entries = []
|
|
24
|
+
yield self if block_given?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def each(&) = @entries.each(&)
|
|
28
|
+
|
|
29
|
+
# Bind a duplicate of this chain to `capsule`. Capsules share the parent
|
|
30
|
+
# config's entries by reference initially but mutate independently after
|
|
31
|
+
# the first `add`/`remove`/etc. (Array#dup on `@entries`).
|
|
32
|
+
def copy_for(capsule)
|
|
33
|
+
copy = dup
|
|
34
|
+
copy.instance_variable_set(:@config, capsule)
|
|
35
|
+
copy
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remove(klass)
|
|
39
|
+
@entries.delete_if { |entry| entry.klass == klass }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add(klass, *)
|
|
43
|
+
remove(klass)
|
|
44
|
+
@entries << Entry.new(klass, *)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def prepend(klass, *)
|
|
48
|
+
remove(klass)
|
|
49
|
+
@entries.unshift(Entry.new(klass, *))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def insert_before(oldklass, newklass, *)
|
|
53
|
+
i = @entries.index { |entry| entry.klass == newklass }
|
|
54
|
+
new_entry = i.nil? ? Entry.new(newklass, *) : @entries.delete_at(i)
|
|
55
|
+
i = @entries.index { |entry| entry.klass == oldklass } || 0
|
|
56
|
+
@entries.insert(i, new_entry)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def insert_after(oldklass, newklass, *)
|
|
60
|
+
i = @entries.index { |entry| entry.klass == newklass }
|
|
61
|
+
new_entry = i.nil? ? Entry.new(newklass, *) : @entries.delete_at(i)
|
|
62
|
+
i = @entries.index { |entry| entry.klass == oldklass } || (@entries.size - 1)
|
|
63
|
+
@entries.insert(i + 1, new_entry)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def exists?(klass)
|
|
67
|
+
any? { |entry| entry.klass == klass }
|
|
68
|
+
end
|
|
69
|
+
alias include? exists?
|
|
70
|
+
|
|
71
|
+
def empty?
|
|
72
|
+
@entries.empty?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def retrieve
|
|
76
|
+
map { |entry| entry.make_new(@config) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clear
|
|
80
|
+
@entries.clear
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Walks the chain inside-out. Each middleware receives a block that
|
|
84
|
+
# advances to the next; the innermost block is the caller's `&block`,
|
|
85
|
+
# whose return value is propagated back out. Empty chain: `yield`.
|
|
86
|
+
def invoke(*args, &block)
|
|
87
|
+
raise ArgumentError, 'middleware chain requires a block' unless block
|
|
88
|
+
return yield if @entries.empty?
|
|
89
|
+
|
|
90
|
+
chain = retrieve
|
|
91
|
+
traverse = lambda do
|
|
92
|
+
if chain.empty?
|
|
93
|
+
block.call
|
|
94
|
+
else
|
|
95
|
+
chain.shift.call(*args, &traverse)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
traverse.call
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Custom dup semantics: deep-copy the entries array so a child chain's
|
|
104
|
+
# mutations don't bleed into the parent (or sibling capsules).
|
|
105
|
+
def initialize_copy(orig)
|
|
106
|
+
super
|
|
107
|
+
@entries = orig.entries.dup
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Holds the klass + ctor args for a registered middleware. Defers
|
|
111
|
+
# instantiation until `retrieve` runs so each job gets a fresh object.
|
|
112
|
+
class Entry
|
|
113
|
+
attr_reader :klass
|
|
114
|
+
|
|
115
|
+
def initialize(klass, *args)
|
|
116
|
+
@klass = klass
|
|
117
|
+
@args = args
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def make_new(config = nil)
|
|
121
|
+
instance = @klass.new(*@args)
|
|
122
|
+
instance.config = config if config && instance.respond_to?(:config=)
|
|
123
|
+
instance
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../middleware'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
module Middleware
|
|
7
|
+
# Propagates `ActiveSupport::CurrentAttributes` from the enqueueing process
|
|
8
|
+
# into the worker. Off by default — host opts in by calling
|
|
9
|
+
# `Wurk::Middleware::CurrentAttributes.persist(klass_or_array)`.
|
|
10
|
+
#
|
|
11
|
+
# One registered class → job hash key `"cattr"`.
|
|
12
|
+
# Multiple → `"cattr"`, `"cattr_1"`, `"cattr_2"`, … (keys mirror Sidekiq's
|
|
13
|
+
# naming exactly: wire-compat sacred).
|
|
14
|
+
#
|
|
15
|
+
# Spec: docs/target/sidekiq-free.md §10.3 and §2.2.
|
|
16
|
+
module CurrentAttributes
|
|
17
|
+
PERSISTENT_KEY = 'cattr'
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Register one or more CurrentAttributes classes. Re-registering is a
|
|
21
|
+
# no-op: `add` already dedupes by klass, so calling `persist` twice
|
|
22
|
+
# with the same set replaces the old entry with the new args.
|
|
23
|
+
def persist(klass_or_array, config = Wurk.configuration)
|
|
24
|
+
classes = Array(klass_or_array)
|
|
25
|
+
raise ArgumentError, 'persist requires at least one CurrentAttributes class' if classes.empty?
|
|
26
|
+
|
|
27
|
+
config.client_middleware.add(Save, classes)
|
|
28
|
+
config.server_middleware.add(Load, classes)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Composes the wire key for the Nth registered class. Sidekiq numbers
|
|
32
|
+
# from 1 ("cattr_1"); index 0 keeps the bare "cattr" key.
|
|
33
|
+
def key_for(index)
|
|
34
|
+
index.zero? ? PERSISTENT_KEY : "#{PERSISTENT_KEY}_#{index}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# AS::CurrentAttributes#attributes returns a HashWithIndifferentAccess;
|
|
38
|
+
# we coerce to a plain Hash so JSON encoding is predictable.
|
|
39
|
+
def snapshot(klass)
|
|
40
|
+
klass.attributes.to_h
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def restore(klass, attrs)
|
|
44
|
+
attrs&.each { |name, value| klass.public_send("#{name}=", value) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Client-side: snapshot each registered CurrentAttributes class into
|
|
49
|
+
# the job hash. Caller-supplied keys take precedence (`||=`).
|
|
50
|
+
class Save
|
|
51
|
+
include Wurk::Middleware::ClientMiddleware
|
|
52
|
+
|
|
53
|
+
def initialize(classes)
|
|
54
|
+
@classes = classes
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def call(_job_class, job, _queue, _redis_pool)
|
|
58
|
+
@classes.each_with_index do |klass, idx|
|
|
59
|
+
key = CurrentAttributes.key_for(idx)
|
|
60
|
+
job[key] ||= CurrentAttributes.snapshot(klass)
|
|
61
|
+
end
|
|
62
|
+
yield
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Restores each registered CurrentAttributes class for the duration
|
|
67
|
+
# of the inner block, then resets so the next job in the thread
|
|
68
|
+
# starts clean. Reset runs in `ensure` to survive raises and Skip.
|
|
69
|
+
class Load
|
|
70
|
+
include Wurk::Middleware::ServerMiddleware
|
|
71
|
+
|
|
72
|
+
def initialize(classes)
|
|
73
|
+
@classes = classes
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call(_job_or_class, job, _queue)
|
|
77
|
+
@classes.each_with_index do |klass, idx|
|
|
78
|
+
CurrentAttributes.restore(klass, job[CurrentAttributes.key_for(idx)])
|
|
79
|
+
end
|
|
80
|
+
yield
|
|
81
|
+
ensure
|
|
82
|
+
@classes.each(&:reset)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../middleware'
|
|
4
|
+
require_relative '../metrics/statsd'
|
|
5
|
+
require_relative '../processor'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
module Middleware
|
|
9
|
+
# Server middleware. Drops jobs whose `expiry` timestamp (stamped at push
|
|
10
|
+
# by the client from `sidekiq_options expires_in:`) has passed before
|
|
11
|
+
# `perform` gets a chance to start. Once `perform` is invoked, expiry no
|
|
12
|
+
# longer preempts — long-running jobs that started in time finish.
|
|
13
|
+
#
|
|
14
|
+
# The skip path:
|
|
15
|
+
# * bumps Wurk::Processor::EXPIRED so the heartbeat flushes
|
|
16
|
+
# `stat:expired` + `stat:expired:YYYY-MM-DD` to Redis, surfacing the
|
|
17
|
+
# count in Wurk::Stats and the dashboard
|
|
18
|
+
# * emits `jobs.expired` via Wurk::Metrics::Statsd (no-op when no client
|
|
19
|
+
# is configured)
|
|
20
|
+
# * returns without yielding — no exception, so JobRetry treats it as a
|
|
21
|
+
# clean exit and the processor acks the UoW
|
|
22
|
+
# * counts as a batch success: because this middleware is registered
|
|
23
|
+
# AFTER `Wurk::Batch::ServerMiddleware`, returning unwinds back through
|
|
24
|
+
# batch's `yield`, and batch's `ack_success` still runs on the way out
|
|
25
|
+
#
|
|
26
|
+
# The expired job is *also* counted toward PROCESSED — Processor#stats's
|
|
27
|
+
# ensure block always increments PROCESSED, so EXPIRED is an additive
|
|
28
|
+
# subset (executed = processed - failed - expired). Matches Sidekiq Pro.
|
|
29
|
+
#
|
|
30
|
+
# Spec: docs/target/sidekiq-pro.md §7.
|
|
31
|
+
class Expiry
|
|
32
|
+
include Wurk::Middleware::ServerMiddleware
|
|
33
|
+
|
|
34
|
+
def call(_job_instance, job, _queue)
|
|
35
|
+
expiry = job['expiry']
|
|
36
|
+
return yield unless expiry
|
|
37
|
+
|
|
38
|
+
if ::Time.now.to_f > expiry.to_f
|
|
39
|
+
Wurk::Processor::EXPIRED.incr
|
|
40
|
+
Wurk::Metrics::Statsd.increment('jobs.expired', tags: ["class:#{job['class']}"])
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
yield
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Wurk.configuration.server_middleware.add(Wurk::Middleware::Expiry)
|