wurk 1.3.1 → 1.5.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 +4 -1
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
- data/lib/wurk/batch/callbacks.rb +82 -12
- data/lib/wurk/batch/death_handler.rb +7 -4
- data/lib/wurk/batch/server_middleware.rb +1 -1
- data/lib/wurk/batch.rb +121 -15
- data/lib/wurk/capsule.rb +45 -16
- data/lib/wurk/cli.rb +48 -14
- data/lib/wurk/client/buffered.rb +200 -43
- data/lib/wurk/client.rb +181 -38
- data/lib/wurk/compat.rb +1 -1
- data/lib/wurk/component.rb +36 -4
- data/lib/wurk/configuration.rb +47 -7
- data/lib/wurk/context.rb +1 -1
- data/lib/wurk/cron.rb +94 -37
- data/lib/wurk/deploy.rb +5 -3
- data/lib/wurk/embedded.rb +13 -0
- data/lib/wurk/engine.rb +41 -1
- data/lib/wurk/errors.rb +15 -0
- data/lib/wurk/fetcher/reaper.rb +125 -58
- data/lib/wurk/fetcher/reliable.rb +366 -51
- data/lib/wurk/fetcher.rb +6 -0
- data/lib/wurk/heartbeat.rb +24 -12
- data/lib/wurk/history.rb +13 -1
- data/lib/wurk/job_logger.rb +16 -7
- data/lib/wurk/job_set.rb +3 -2
- data/lib/wurk/job_util.rb +44 -24
- data/lib/wurk/launcher.rb +230 -119
- data/lib/wurk/leader.rb +63 -14
- data/lib/wurk/limiter/base.rb +8 -10
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/concurrent.rb +27 -22
- data/lib/wurk/limiter/window.rb +13 -11
- data/lib/wurk/limiter.rb +7 -4
- data/lib/wurk/logger.rb +1 -1
- data/lib/wurk/lua/loader.rb +9 -3
- data/lib/wurk/lua.rb +97 -14
- data/lib/wurk/manager.rb +29 -13
- data/lib/wurk/metrics/accumulator.rb +95 -0
- data/lib/wurk/metrics/flusher.rb +70 -0
- data/lib/wurk/metrics/history.rb +102 -32
- data/lib/wurk/metrics/queue_rollup.rb +13 -1
- data/lib/wurk/metrics/rollup.rb +13 -1
- data/lib/wurk/metrics/statsd.rb +32 -18
- data/lib/wurk/middleware/chain.rb +31 -14
- data/lib/wurk/middleware/interrupt_handler.rb +7 -6
- data/lib/wurk/middleware/poison_pill.rb +93 -31
- data/lib/wurk/middleware.rb +2 -2
- data/lib/wurk/pool_checkout.rb +39 -0
- data/lib/wurk/process_set.rb +10 -5
- data/lib/wurk/processor.rb +83 -9
- data/lib/wurk/profiler.rb +9 -4
- data/lib/wurk/queue.rb +18 -7
- data/lib/wurk/rails_boot.rb +38 -7
- data/lib/wurk/redis_client_adapter.rb +49 -5
- data/lib/wurk/redis_pool.rb +71 -25
- data/lib/wurk/scheduled.rb +30 -2
- data/lib/wurk/shutdown_gate.rb +79 -0
- data/lib/wurk/stats.rb +19 -10
- data/lib/wurk/swarm/child_boot.rb +36 -4
- data/lib/wurk/swarm.rb +258 -43
- data/lib/wurk/timer_loop.rb +14 -0
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +11 -7
- data/lib/wurk/web/enterprise.rb +58 -6
- data/lib/wurk/web/extension.rb +1 -1
- data/lib/wurk/web/search.rb +5 -3
- data/lib/wurk.rb +12 -10
- data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
- data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
- data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
- data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
- data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
- data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
- data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
- data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +32 -27
- data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
- data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
- data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
module Metrics
|
|
5
|
+
# The in-memory half of Wurk::Metrics::History. Every execution folds into a
|
|
6
|
+
# `{pool => {minute => {class => [processed, failed, ms]}}}` tree under one
|
|
7
|
+
# mutex; Wurk::Metrics::Flusher drains it into Redis on a timer. `HINCRBY`
|
|
8
|
+
# is additive, so N folded executions leave Redis in the same state as the N
|
|
9
|
+
# individual pipelines the hot path used to send.
|
|
10
|
+
#
|
|
11
|
+
# Knows nothing about Redis — History owns the write, this owns the counts.
|
|
12
|
+
#
|
|
13
|
+
# The minute is the bucket the job *ran* in, decided by the caller at record
|
|
14
|
+
# time. That is what keeps the batching a visibility lag instead of a
|
|
15
|
+
# misattribution: a job that runs at 12:03:59 and flushes at 12:04:02 still
|
|
16
|
+
# counts toward 12:03. Signed off in
|
|
17
|
+
# docs/plans/2026/08/06/101-faster-than-sidekiq/00-semantics-signoff.md §2.
|
|
18
|
+
#
|
|
19
|
+
# Keyed by the pool the recording middleware handed us (nil = the process
|
|
20
|
+
# default), so a multi-capsule process flushes each capsule's counts through
|
|
21
|
+
# the pool that capsule's jobs ran on.
|
|
22
|
+
class Accumulator
|
|
23
|
+
# Ceiling on minute buckets retained per pool. Only reachable through
|
|
24
|
+
# #merge_back: a flush that keeps failing puts its counts back every tick,
|
|
25
|
+
# and a Redis outage that outlives the process would otherwise grow a
|
|
26
|
+
# structure fed by the job hot path without bound. The newest buckets win
|
|
27
|
+
# — they are the ones the dashboard is still drawing — and the oldest are
|
|
28
|
+
# dropped, which these counters have always been allowed to do (a Redis
|
|
29
|
+
# failure during a metrics write has never changed a job's outcome).
|
|
30
|
+
MAX_RETAINED_MINUTES = 60
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@lock = ::Mutex.new
|
|
34
|
+
@pools = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Hot path. Three hash lookups under the mutex; allocates only the first
|
|
38
|
+
# time a (pool, minute, class) triple is seen.
|
|
39
|
+
def add(pool, klass, minute, ms, success)
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
counts = (((@pools[pool] ||= {})[minute] ||= {})[klass] ||= [0, 0, 0])
|
|
42
|
+
counts[success ? 0 : 1] += 1
|
|
43
|
+
counts[2] += ms
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Hands the whole tree over and starts a fresh one, so recording never
|
|
48
|
+
# blocks behind the flush's Redis round trip.
|
|
49
|
+
def drain
|
|
50
|
+
@lock.synchronize do
|
|
51
|
+
drained = @pools
|
|
52
|
+
@pools = {}
|
|
53
|
+
drained
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Puts one pool's counts back after its write failed, so the next tick
|
|
58
|
+
# retries them instead of dropping the window. Merged rather than
|
|
59
|
+
# assigned: jobs kept recording into the fresh tree while the flush was in
|
|
60
|
+
# flight, and those counts have not been written yet either.
|
|
61
|
+
def merge_back(pool, minutes)
|
|
62
|
+
@lock.synchronize do
|
|
63
|
+
into = (@pools[pool] ||= {})
|
|
64
|
+
minutes.each { |minute, classes| merge_minute(into, minute, classes) }
|
|
65
|
+
trim(into)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def empty?
|
|
70
|
+
@lock.synchronize { @pools.empty? }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def merge_minute(into, minute, classes)
|
|
76
|
+
target = (into[minute] ||= {})
|
|
77
|
+
classes.each do |klass, counts|
|
|
78
|
+
existing = target[klass]
|
|
79
|
+
if existing
|
|
80
|
+
counts.each_with_index { |count, i| existing[i] += count }
|
|
81
|
+
else
|
|
82
|
+
target[klass] = counts
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def trim(minutes)
|
|
88
|
+
excess = minutes.size - MAX_RETAINED_MINUTES
|
|
89
|
+
return if excess <= 0
|
|
90
|
+
|
|
91
|
+
minutes.keys.min(excess).each { |minute| minutes.delete(minute) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../component'
|
|
4
|
+
require_relative '../timer_loop'
|
|
5
|
+
require_relative 'history'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
module Metrics
|
|
9
|
+
# Drains Wurk::Metrics::History's in-process accumulator into Redis every
|
|
10
|
+
# `History::FLUSH_INTERVAL` seconds.
|
|
11
|
+
#
|
|
12
|
+
# Deliberately NOT leader-gated, unlike every other periodic component here
|
|
13
|
+
# (Metrics::Rollup, Metrics::QueueRollup, Wurk::History). Those publish
|
|
14
|
+
# *cluster* state that one process should own; this publishes counters that
|
|
15
|
+
# only exist inside this process's memory and that no other process can
|
|
16
|
+
# write. A leader gate would strand every follower's metrics until it died.
|
|
17
|
+
#
|
|
18
|
+
# One tick per process, not per capsule: the accumulator is process-wide and
|
|
19
|
+
# already keyed by the pool each capsule's jobs recorded through.
|
|
20
|
+
class Flusher
|
|
21
|
+
include Component
|
|
22
|
+
|
|
23
|
+
# `accumulator` is a collaborator, not an option: every real boot flushes
|
|
24
|
+
# the process-wide one. Tests inject their own so a broken pool stays
|
|
25
|
+
# inside the test that built it.
|
|
26
|
+
def initialize(config, accumulator: History::ACCUMULATOR)
|
|
27
|
+
@config = config
|
|
28
|
+
@accumulator = accumulator
|
|
29
|
+
@timer = TimerLoop.new(History::FLUSH_INTERVAL)
|
|
30
|
+
@thread = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start
|
|
34
|
+
return @thread if @thread
|
|
35
|
+
|
|
36
|
+
@timer.reset
|
|
37
|
+
@thread = safe_thread('metrics-flush') { @timer.run { tick } }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Flushes once more in an `ensure`: this stops the only thread that would
|
|
41
|
+
# have written the window accumulated since the last tick, so without it a
|
|
42
|
+
# graceful shutdown would drop up to FLUSH_INTERVAL of counters — the cost
|
|
43
|
+
# the sign-off accepts for a *hard* kill only.
|
|
44
|
+
#
|
|
45
|
+
# Launcher#stop joins the manager drains before the teardown tail reaches
|
|
46
|
+
# us, so by the time this runs no job is still recording.
|
|
47
|
+
#
|
|
48
|
+
# Cleared only on a confirmed join (Thread#join returns nil on timeout): a
|
|
49
|
+
# wedged thread must stay tracked so #start's guard returns it instead of
|
|
50
|
+
# calling @timer.reset, which would un-terminate the loop it is still
|
|
51
|
+
# inside and leave two threads draining the same accumulator.
|
|
52
|
+
def terminate
|
|
53
|
+
@timer.terminate
|
|
54
|
+
@thread = nil if @thread&.join(TimerLoop::JOIN_TIMEOUT)
|
|
55
|
+
ensure
|
|
56
|
+
tick
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Never raises: this runs on a `safe_thread`, whose watchdog re-raises
|
|
60
|
+
# after reporting, and a reported-then-dead flusher would silently stop
|
|
61
|
+
# every counter in the process for a blip Redis recovers from. The
|
|
62
|
+
# accumulator has already merged the failed window back for the next tick.
|
|
63
|
+
def tick
|
|
64
|
+
History.flush(@accumulator)
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
handle_exception(e, { context: 'metrics-flush' })
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/wurk/metrics/history.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../middleware'
|
|
4
|
+
require_relative '../pool_checkout'
|
|
5
|
+
require_relative 'accumulator'
|
|
4
6
|
|
|
5
7
|
module Wurk
|
|
6
8
|
module Metrics
|
|
@@ -30,9 +32,10 @@ module Wurk
|
|
|
30
32
|
# running we keep its bucket around for the retention window measured from
|
|
31
33
|
# *last write*, not from first write. So we EXPIRE unconditionally.
|
|
32
34
|
#
|
|
33
|
-
# The middleware is hot-path — every
|
|
34
|
-
#
|
|
35
|
-
#
|
|
35
|
+
# The middleware is hot-path — every job pays for it — so it does not touch
|
|
36
|
+
# Redis at all. Each execution folds into a process-wide Accumulator and
|
|
37
|
+
# Wurk::Metrics::Flusher writes the whole tree out every FLUSH_INTERVAL, in
|
|
38
|
+
# the same 6-commands-per-(class, minute) shape the per-job pipeline sent.
|
|
36
39
|
class History
|
|
37
40
|
include Wurk::Middleware::ServerMiddleware
|
|
38
41
|
|
|
@@ -43,6 +46,22 @@ module Wurk
|
|
|
43
46
|
MINUTE_KEY_PREFIX = 'j|'
|
|
44
47
|
DATE_FORMAT = '%Y%m%d' # YYYYMMDD — matches Sidekiq's j| key
|
|
45
48
|
|
|
49
|
+
# Ceiling on how stale an unflushed counter can be, in seconds. A
|
|
50
|
+
# constant, never a config knob: it exists to bound the dashboard's lag,
|
|
51
|
+
# not to be tuned. Sidekiq — the engine these numbers get compared
|
|
52
|
+
# against — flushes its own process stats on a 10s heartbeat and writes
|
|
53
|
+
# nothing per job, so this is the tighter of the two. Signed off in
|
|
54
|
+
# docs/plans/2026/08/06/101-faster-than-sidekiq/00-semantics-signoff.md §2.
|
|
55
|
+
FLUSH_INTERVAL = 5
|
|
56
|
+
|
|
57
|
+
# Process-wide, like Processor::PROCESSED — the counters belong to the
|
|
58
|
+
# process, not to a middleware instance (the chain builds one per capsule)
|
|
59
|
+
# and not to a job.
|
|
60
|
+
ACCUMULATOR = Accumulator.new
|
|
61
|
+
|
|
62
|
+
# Hourly buckets are already class-scoped, so their fields are bare.
|
|
63
|
+
HOUR_FIELDS = %w[p f ms].freeze
|
|
64
|
+
|
|
46
65
|
def call(_worker, job, _queue)
|
|
47
66
|
klass = job['class']
|
|
48
67
|
started = monotonic_ms
|
|
@@ -53,8 +72,11 @@ module Wurk
|
|
|
53
72
|
result
|
|
54
73
|
ensure
|
|
55
74
|
duration = (monotonic_ms - started).round
|
|
56
|
-
# Best-effort: a metrics
|
|
57
|
-
#
|
|
75
|
+
# Best-effort: a metrics failure must never propagate into the job
|
|
76
|
+
# result. The processor already finalized the ack path. Recording is
|
|
77
|
+
# in-memory now, so what this catches is a bad payload (a `class` that
|
|
78
|
+
# is not a String) rather than a Redis blip — the Redis half moved to
|
|
79
|
+
# Flusher, which reports on its own thread.
|
|
58
80
|
begin
|
|
59
81
|
self.class.record(klass, duration, success: success, redis_pool: redis_pool)
|
|
60
82
|
rescue StandardError => e
|
|
@@ -64,39 +86,46 @@ module Wurk
|
|
|
64
86
|
end
|
|
65
87
|
|
|
66
88
|
class << self
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
89
|
+
# No Redis. `success: true` → `<klass>|p`; `success: false` →
|
|
90
|
+
# `<klass>|f`. `<klass>|ms` accumulates total runtime in milliseconds
|
|
91
|
+
# for *both* outcomes (so an operator can ask "how much wall-clock time
|
|
92
|
+
# has FooJob consumed?" without branching on outcome).
|
|
93
|
+
#
|
|
94
|
+
# `at:` defaults to nil rather than `::Time.now`: this runs once per job
|
|
95
|
+
# and the bucket only needs whole UTC minutes, which #minute_bucket
|
|
96
|
+
# reads straight off the clock as an Integer.
|
|
97
|
+
def record(klass, duration_ms, success:, redis_pool: nil, at: nil)
|
|
73
98
|
return if klass.nil? || klass.empty?
|
|
74
99
|
|
|
75
100
|
ms = duration_ms.to_i
|
|
76
101
|
ms = 0 if ms.negative?
|
|
77
|
-
|
|
78
|
-
with_pool(redis_pool) { |conn| pipeline_write(conn, klass, ms, success, buckets) }
|
|
102
|
+
ACCUMULATOR.add(redis_pool, klass, minute_bucket(at), ms, success)
|
|
79
103
|
nil
|
|
80
104
|
end
|
|
81
105
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
# Drains an accumulator into Redis, one pipeline per pool. Raises the
|
|
107
|
+
# first pool's failure after every other pool has had its turn —
|
|
108
|
+
# Wurk::Metrics::Flusher owns turning that into an error-handler call.
|
|
109
|
+
#
|
|
110
|
+
# Only the *failed* pool's counts are merged back. Putting the whole
|
|
111
|
+
# drained tree back would re-send the writes that already landed, and
|
|
112
|
+
# HINCRBY would count them twice.
|
|
113
|
+
#
|
|
114
|
+
# The argument is a collaborator, not an option: a process has exactly
|
|
115
|
+
# one accumulator and nothing in wurk passes a second. Tests pass their
|
|
116
|
+
# own so a deliberately broken pool cannot leak into a parallel test's
|
|
117
|
+
# flush through the process-wide one.
|
|
118
|
+
def flush(accumulator = ACCUMULATOR)
|
|
119
|
+
error = nil
|
|
120
|
+
accumulator.drain.each do |pool, minutes|
|
|
121
|
+
write(pool, minutes)
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
accumulator.merge_back(pool, minutes)
|
|
124
|
+
error = e
|
|
92
125
|
end
|
|
93
|
-
|
|
126
|
+
raise error if error
|
|
94
127
|
|
|
95
|
-
|
|
96
|
-
outcome_field, ms_field, ms, ttl = parts
|
|
97
|
-
pipe.call('HINCRBY', key, outcome_field, 1)
|
|
98
|
-
pipe.call('HINCRBY', key, ms_field, ms)
|
|
99
|
-
pipe.call('EXPIRE', key, ttl)
|
|
128
|
+
nil
|
|
100
129
|
end
|
|
101
130
|
|
|
102
131
|
# Public formatters — Wurk::Metrics::Query reuses these so the two
|
|
@@ -111,6 +140,47 @@ module Wurk
|
|
|
111
140
|
t = time.utc
|
|
112
141
|
"#{klass}-#{t.strftime(DATE_FORMAT)}-#{t.hour}"
|
|
113
142
|
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# Whole UTC minutes since the epoch. Integer arithmetic on a raw clock
|
|
147
|
+
# read, so nothing is allocated per job.
|
|
148
|
+
def minute_bucket(at)
|
|
149
|
+
return ::Process.clock_gettime(::Process::CLOCK_REALTIME, :second) / 60 unless at
|
|
150
|
+
|
|
151
|
+
at.to_i / 60
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def write(pool, minutes)
|
|
155
|
+
with_pool(pool) do |conn|
|
|
156
|
+
conn.pipelined do |pipe|
|
|
157
|
+
minutes.each do |minute, classes|
|
|
158
|
+
at = ::Time.at(minute * 60).utc
|
|
159
|
+
key = minute_key(at)
|
|
160
|
+
classes.each { |klass, counts| write_class(pipe, key, at, klass, counts) }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def write_class(pipe, bucket_key, at, klass, counts)
|
|
167
|
+
incr_bucket(pipe, bucket_key, ["#{klass}|p", "#{klass}|f", "#{klass}|ms"], counts)
|
|
168
|
+
incr_bucket(pipe, hour_key(klass, at), HOUR_FIELDS, counts)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# `p`/`f` are emitted only when non-zero, so a bucket ends up with the
|
|
172
|
+
# exact field set the per-job path produced: `HINCRBY <field> 0` would
|
|
173
|
+
# materialize a counter no unbatched write ever created, and the read
|
|
174
|
+
# side would start reporting a `f: 0` series for classes that have never
|
|
175
|
+
# failed. `ms` is unconditional — the per-job path always incremented
|
|
176
|
+
# it, including by zero.
|
|
177
|
+
def incr_bucket(pipe, key, fields, counts)
|
|
178
|
+
processed, failed, ms = counts
|
|
179
|
+
pipe.call('HINCRBY', key, fields[0], processed) if processed.positive?
|
|
180
|
+
pipe.call('HINCRBY', key, fields[1], failed) if failed.positive?
|
|
181
|
+
pipe.call('HINCRBY', key, fields[2], ms)
|
|
182
|
+
pipe.call('EXPIRE', key, MID_TERM)
|
|
183
|
+
end
|
|
114
184
|
end
|
|
115
185
|
|
|
116
186
|
private
|
|
@@ -124,11 +194,11 @@ module Wurk
|
|
|
124
194
|
cfg.handle_exception(err, context: 'Wurk::Metrics::History')
|
|
125
195
|
end
|
|
126
196
|
|
|
127
|
-
def self.with_pool(pool, &)
|
|
197
|
+
def self.with_pool(pool, idempotent: false, &)
|
|
128
198
|
if pool
|
|
129
|
-
|
|
199
|
+
PoolCheckout.with(pool, idempotent, &)
|
|
130
200
|
else
|
|
131
|
-
Wurk.redis(&)
|
|
201
|
+
Wurk.redis(idempotent:, &)
|
|
132
202
|
end
|
|
133
203
|
end
|
|
134
204
|
private_class_method :with_pool
|
|
@@ -51,11 +51,23 @@ module Wurk
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def start
|
|
54
|
-
@thread
|
|
54
|
+
return @thread if @thread
|
|
55
|
+
|
|
56
|
+
@timer.reset
|
|
57
|
+
@thread = safe_thread('queue-metrics') { @timer.run { tick } }
|
|
55
58
|
end
|
|
56
59
|
|
|
60
|
+
# Blocks until the thread is really gone: the launcher releases the
|
|
61
|
+
# cluster lock immediately after this returns, and a sample still in
|
|
62
|
+
# flight would write the same buckets as the next leader's first one.
|
|
63
|
+
#
|
|
64
|
+
# Cleared only on a confirmed join (Thread#join returns nil on timeout):
|
|
65
|
+
# a wedged thread must stay tracked so #start's guard returns it instead
|
|
66
|
+
# of calling @timer.reset, which would un-terminate the loop it is still
|
|
67
|
+
# inside and leave two threads HSETting the same buckets.
|
|
57
68
|
def terminate
|
|
58
69
|
@timer.terminate
|
|
70
|
+
@thread = nil if @thread&.join(TimerLoop::JOIN_TIMEOUT)
|
|
59
71
|
end
|
|
60
72
|
|
|
61
73
|
# Leader-gated: only the elected leader samples, so N workers don't each
|
data/lib/wurk/metrics/rollup.rb
CHANGED
|
@@ -59,11 +59,23 @@ module Wurk
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def start
|
|
62
|
-
@thread
|
|
62
|
+
return @thread if @thread
|
|
63
|
+
|
|
64
|
+
@timer.reset
|
|
65
|
+
@thread = safe_thread('metrics-rollup') { @timer.run { tick } }
|
|
63
66
|
end
|
|
64
67
|
|
|
68
|
+
# Blocks until the thread is really gone: the launcher releases the
|
|
69
|
+
# cluster lock immediately after this returns, and a roll still in flight
|
|
70
|
+
# would write the same buckets as the next leader's first one.
|
|
71
|
+
#
|
|
72
|
+
# Cleared only on a confirmed join (Thread#join returns nil on timeout):
|
|
73
|
+
# a wedged thread must stay tracked so #start's guard returns it instead
|
|
74
|
+
# of calling @timer.reset, which would un-terminate the loop it is still
|
|
75
|
+
# inside and leave two threads HSETting the same buckets.
|
|
65
76
|
def terminate
|
|
66
77
|
@timer.terminate
|
|
78
|
+
@thread = nil if @thread&.join(TimerLoop::JOIN_TIMEOUT)
|
|
67
79
|
end
|
|
68
80
|
|
|
69
81
|
# Leader-gated: only the elected leader writes the cluster-total series,
|
data/lib/wurk/metrics/statsd.rb
CHANGED
|
@@ -35,6 +35,15 @@ module Wurk
|
|
|
35
35
|
METRIC_PREFIX = 'sidekiq.'
|
|
36
36
|
DEFAULT_SAMPLE_RATE = 1.0
|
|
37
37
|
|
|
38
|
+
# Unresolved state of the {.client} memo, distinct from a resolved nil.
|
|
39
|
+
UNSET = :unset
|
|
40
|
+
private_constant :UNSET
|
|
41
|
+
|
|
42
|
+
# Seeded in the class body (not `class << self`, where `self` is the
|
|
43
|
+
# singleton class) so {.client} is a bare ivar read with no `defined?`
|
|
44
|
+
# guard on the hot path.
|
|
45
|
+
@client = UNSET
|
|
46
|
+
|
|
38
47
|
class << self
|
|
39
48
|
attr_accessor :options
|
|
40
49
|
|
|
@@ -91,19 +100,35 @@ module Wurk
|
|
|
91
100
|
# Resolves the live client: invokes the configured `dogstatsd` proc
|
|
92
101
|
# exactly once per process and memoizes. Returns nil when no proc
|
|
93
102
|
# is configured, so callers get a clean no-op without raising.
|
|
103
|
+
#
|
|
104
|
+
# The *unconfigured* answer is memoized too, which is what makes the
|
|
105
|
+
# no-client path free: Client#emit_enqueued asks once per payload, so
|
|
106
|
+
# re-reading `Wurk.configuration` here would cost a config lookup per
|
|
107
|
+
# job on every bulk push. Both invalidation points call {.reset!}.
|
|
94
108
|
def client
|
|
95
|
-
|
|
109
|
+
memo = @client
|
|
110
|
+
return memo unless UNSET.equal?(memo)
|
|
96
111
|
|
|
97
112
|
builder = Wurk.configuration.respond_to?(:dogstatsd) ? Wurk.configuration.dogstatsd : nil
|
|
98
|
-
return nil if builder.nil?
|
|
99
|
-
|
|
100
113
|
@client = builder.respond_to?(:call) ? builder.call : builder
|
|
101
114
|
end
|
|
102
115
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
116
|
+
# {.client} without the raise: a builder proc that blows up is reported
|
|
117
|
+
# through the error handler and treated as "no client", so misconfigured
|
|
118
|
+
# metrics can never fail the work they were measuring. Callers on a
|
|
119
|
+
# hot path use this to skip building anything the emit would drop.
|
|
120
|
+
def safe_client
|
|
121
|
+
client
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
handle_error(e)
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Test/lifecycle hook. Reset between specs, after fork so the parent's
|
|
128
|
+
# socket doesn't bleed into children, and on every `config.dogstatsd=`
|
|
129
|
+
# — a memoized nil would otherwise hide a client configured later.
|
|
105
130
|
def reset!
|
|
106
|
-
@client =
|
|
131
|
+
@client = UNSET
|
|
107
132
|
end
|
|
108
133
|
|
|
109
134
|
private
|
|
@@ -118,8 +143,7 @@ module Wurk
|
|
|
118
143
|
end
|
|
119
144
|
|
|
120
145
|
def call(_worker, job, queue) # rubocop:disable Metrics/AbcSize
|
|
121
|
-
|
|
122
|
-
return yield if client.nil?
|
|
146
|
+
return yield if self.class.safe_client.nil?
|
|
123
147
|
|
|
124
148
|
klass = job['class']
|
|
125
149
|
opts = per_job_options(klass, job, queue)
|
|
@@ -146,16 +170,6 @@ module Wurk
|
|
|
146
170
|
|
|
147
171
|
private
|
|
148
172
|
|
|
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
173
|
# Per-spec §9.2: caller-supplied proc may override tags / sample_rate
|
|
160
174
|
# on a per-job basis. The job's own `dd_rate` field, when present,
|
|
161
175
|
# always wins — it's the per-push override hinted in §8.
|
|
@@ -7,9 +7,10 @@ module Wurk
|
|
|
7
7
|
# pushes to index 0, `insert_before`/`insert_after` anchor relative to an
|
|
8
8
|
# existing entry, `invoke` walks the chain handing the inner block to each.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# Entries store klass + ctor args, not the live object: every job gets a
|
|
11
|
+
# fresh instance per entry (`invoke` builds each as it reaches it, `retrieve`
|
|
12
|
+
# builds them all up front). This keeps middleware thread-safe by
|
|
13
|
+
# construction and matches Sidekiq's lifecycle.
|
|
13
14
|
#
|
|
14
15
|
# Spec: docs/target/sidekiq-free.md §10.1.
|
|
15
16
|
class Chain
|
|
@@ -83,23 +84,28 @@ module Wurk
|
|
|
83
84
|
# Walks the chain inside-out. Each middleware receives a block that
|
|
84
85
|
# advances to the next; the innermost block is the caller's `&block`,
|
|
85
86
|
# whose return value is propagated back out. Empty chain: `yield`.
|
|
87
|
+
#
|
|
88
|
+
# Hot path — one job pays this per invocation, so instantiation is folded
|
|
89
|
+
# into an index walk over `@entries`: no instance array from `retrieve`,
|
|
90
|
+
# no traversal lambda, and no `shift` (which memmoves the array per entry).
|
|
91
|
+
# Cost is one allocation per entry (the middleware itself), zero to walk.
|
|
86
92
|
def invoke(*args, &block)
|
|
87
93
|
raise ArgumentError, 'middleware chain requires a block' unless block
|
|
88
94
|
return yield if @entries.empty?
|
|
89
95
|
|
|
90
|
-
|
|
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
|
|
96
|
+
traverse(@entries, 0, args, &block)
|
|
99
97
|
end
|
|
100
98
|
|
|
101
99
|
private
|
|
102
100
|
|
|
101
|
+
def traverse(entries, index, args, &block)
|
|
102
|
+
return yield if index == entries.size
|
|
103
|
+
|
|
104
|
+
entries[index].make_new(@config).call(*args) do
|
|
105
|
+
traverse(entries, index + 1, args, &block)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
103
109
|
# Custom dup semantics: deep-copy the entries array so a child chain's
|
|
104
110
|
# mutations don't bleed into the parent (or sibling capsules).
|
|
105
111
|
def initialize_copy(orig)
|
|
@@ -108,18 +114,29 @@ module Wurk
|
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
# Holds the klass + ctor args for a registered middleware. Defers
|
|
111
|
-
# instantiation until `retrieve` runs so each job gets a fresh
|
|
117
|
+
# instantiation until `retrieve`/`invoke` runs so each job gets a fresh
|
|
118
|
+
# object.
|
|
119
|
+
#
|
|
120
|
+
# Whether the class declares `config=` is resolved once, at registration,
|
|
121
|
+
# so the common case (the setter comes from `include
|
|
122
|
+
# Wurk::Middleware::ServerMiddleware`) skips a `respond_to?` on every job
|
|
123
|
+
# × every entry. It is a fast path, not the rule: the contract is "set
|
|
124
|
+
# `config` if the instance is respondable" (spec §10.1), which also covers
|
|
125
|
+
# a setter defined on the singleton in `initialize`, one reached through
|
|
126
|
+
# `method_missing`, or one added to the class after it was registered — so
|
|
127
|
+
# a class that does not declare it still gets asked.
|
|
112
128
|
class Entry
|
|
113
129
|
attr_reader :klass
|
|
114
130
|
|
|
115
131
|
def initialize(klass, *args)
|
|
116
132
|
@klass = klass
|
|
117
133
|
@args = args
|
|
134
|
+
@takes_config = klass.public_method_defined?(:config=)
|
|
118
135
|
end
|
|
119
136
|
|
|
120
137
|
def make_new(config = nil)
|
|
121
138
|
instance = @klass.new(*@args)
|
|
122
|
-
instance.config = config if config && instance.respond_to?(:config=)
|
|
139
|
+
instance.config = config if config && (@takes_config || instance.respond_to?(:config=))
|
|
123
140
|
instance
|
|
124
141
|
end
|
|
125
142
|
end
|
|
@@ -8,14 +8,15 @@ module Wurk
|
|
|
8
8
|
module Middleware
|
|
9
9
|
# Server middleware. Catches `Wurk::Job::Interrupted` raised by an
|
|
10
10
|
# IterableJob mid-iteration (or any cooperatively-cancelled job),
|
|
11
|
-
# re-pushes the job to the
|
|
11
|
+
# re-pushes the job to the tail of its queue so it resumes from the
|
|
12
12
|
# persisted cursor, and raises `Wurk::JobRetry::Skip` so the retry
|
|
13
13
|
# layer treats this as a clean exit rather than an error.
|
|
14
14
|
#
|
|
15
|
-
# The re-push uses
|
|
16
|
-
# one to be fetched
|
|
17
|
-
#
|
|
18
|
-
#
|
|
15
|
+
# The re-push uses RPUSH (tail of queue) so the same job is the next
|
|
16
|
+
# one to be fetched: the fetcher's LMOVE pops from the RIGHT (tail),
|
|
17
|
+
# so this job is fetched ahead of fresh LPUSH'd enqueues. The job
|
|
18
|
+
# JSON is unchanged: cursor state lives in the `it-<jid>` HASH (see
|
|
19
|
+
# IterableJob persistence), not in the payload.
|
|
19
20
|
#
|
|
20
21
|
# Auto-registered at the top of the server chain when this file is
|
|
21
22
|
# required. Top-of-chain is important: a downstream middleware must
|
|
@@ -36,7 +37,7 @@ module Wurk
|
|
|
36
37
|
|
|
37
38
|
def repush(job, queue)
|
|
38
39
|
payload = Wurk.dump_json(job)
|
|
39
|
-
redis_pool.with { |conn| conn.call('
|
|
40
|
+
redis_pool.with { |conn| conn.call('RPUSH', "queue:#{queue}", payload) }
|
|
40
41
|
end
|
|
41
42
|
end
|
|
42
43
|
end
|