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,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'component'
|
|
4
|
+
require_relative 'keys'
|
|
5
|
+
require_relative 'processor'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
# Single-purpose owner of the Redis-side process heartbeat. Lifted out of
|
|
9
|
+
# Wurk::Launcher so the launcher can stay focused on lifecycle and so the
|
|
10
|
+
# heartbeat schema lives in one place readers can grep for.
|
|
11
|
+
#
|
|
12
|
+
# Each beat is one pipelined round-trip:
|
|
13
|
+
# SADD processes <identity>
|
|
14
|
+
# HSET <identity> info concurrency busy beat quiet rss rtt_us
|
|
15
|
+
# EXPIRE <identity> 60
|
|
16
|
+
# UNLINK <identity>:work
|
|
17
|
+
# HSET <identity>:work <tid> <json> ... (only if WORK_STATE non-empty)
|
|
18
|
+
# EXPIRE <identity>:work 60 (only if WORK_STATE non-empty)
|
|
19
|
+
# LPOP <identity>-signals × BEAT_PAUSE
|
|
20
|
+
#
|
|
21
|
+
# The work hash is UNLINK-then-rewritten on every beat — a dropped beat
|
|
22
|
+
# momentarily empties it, and ProcessSet#cleanup compensates by SREM-ing
|
|
23
|
+
# identities whose `info` field has expired.
|
|
24
|
+
#
|
|
25
|
+
# Heartbeat owns only the wire. Signals queued by the dashboard are pulled
|
|
26
|
+
# out of the pipelined results and returned to the caller for dispatch —
|
|
27
|
+
# TSTP/TERM semantics live in Launcher.
|
|
28
|
+
#
|
|
29
|
+
# `:heartbeat` fires once on the first successful beat and again after any
|
|
30
|
+
# network partition (rearmed when beat! rescues). `:beat` fires every tick.
|
|
31
|
+
#
|
|
32
|
+
# Spec: docs/target/sidekiq-free.md §12 (Launcher#❤️).
|
|
33
|
+
class Heartbeat
|
|
34
|
+
include Component
|
|
35
|
+
|
|
36
|
+
# Cadence in seconds. Key TTL is 60s — a process is dead after ~6 misses.
|
|
37
|
+
BEAT_PAUSE = 10
|
|
38
|
+
TTL_SECONDS = 60
|
|
39
|
+
|
|
40
|
+
attr_reader :identity, :rtt_us, :last_beat_at
|
|
41
|
+
|
|
42
|
+
# `quiet:` is a callable so Launcher can keep ownership of its `@done`
|
|
43
|
+
# flag without an awkward setter contract. `info_overrides:` lets the
|
|
44
|
+
# caller (Launcher#embedded, tests) inject fields without forcing
|
|
45
|
+
# Heartbeat to know about every flag the host process tracks.
|
|
46
|
+
def initialize(identity:, config:, started_at: nil, embedded: false, quiet: nil)
|
|
47
|
+
@identity = identity
|
|
48
|
+
@config = config
|
|
49
|
+
@started_at = started_at || Time.now.to_f
|
|
50
|
+
@embedded = embedded
|
|
51
|
+
@quiet_proc = quiet || -> { false }
|
|
52
|
+
@first_heartbeat = true
|
|
53
|
+
@rtt_us = 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Pipelined beat. Returns the drained signals as Array<String> on
|
|
57
|
+
# success, or nil if Redis raised — the next successful beat refires
|
|
58
|
+
# `:heartbeat` so partition recovery is observable.
|
|
59
|
+
def beat!
|
|
60
|
+
sigs, @rtt_us = pipelined_beat
|
|
61
|
+
@last_beat_at = Time.now.to_f
|
|
62
|
+
if @first_heartbeat
|
|
63
|
+
@first_heartbeat = false
|
|
64
|
+
fire_event(:heartbeat)
|
|
65
|
+
end
|
|
66
|
+
fire_event(:beat, oneshot: false)
|
|
67
|
+
emit_statsd_gauges
|
|
68
|
+
sigs.compact
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
@first_heartbeat = true
|
|
71
|
+
handle_exception(e, { context: 'heartbeat' })
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Best-effort teardown. SREM removes us from the live list; UNLINK
|
|
76
|
+
# drops the identity hash and its work mirror. Idempotent: if Redis
|
|
77
|
+
# is down the next process boot's ProcessSet#cleanup prunes us.
|
|
78
|
+
def stop!
|
|
79
|
+
redis do |conn|
|
|
80
|
+
conn.pipelined do |pipe|
|
|
81
|
+
pipe.call('SREM', Keys::PROCESSES, @identity)
|
|
82
|
+
pipe.call('UNLINK', @identity, "#{@identity}:work")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Two extra writes (HSET + EXPIRE for the work mirror) when WORK_STATE
|
|
92
|
+
# has entries, so `lead` shifts to skip them when slicing signals out
|
|
93
|
+
# of the pipeline result.
|
|
94
|
+
def pipelined_beat
|
|
95
|
+
work_snapshot = Processor::WORK_STATE.dup
|
|
96
|
+
lead = 4 + (work_snapshot.empty? ? 0 : 2)
|
|
97
|
+
t0 = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :microsecond)
|
|
98
|
+
results = redis { |conn| conn.pipelined { |pipe| write_beat(pipe, work_snapshot) } }
|
|
99
|
+
rtt = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :microsecond) - t0
|
|
100
|
+
[results[lead, BEAT_PAUSE] || [], rtt]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def write_beat(pipe, work_snapshot)
|
|
104
|
+
pipe.call('SADD', Keys::PROCESSES, @identity)
|
|
105
|
+
pipe.call('HSET', @identity, *beat_hash_args(work_snapshot.size))
|
|
106
|
+
pipe.call('EXPIRE', @identity, TTL_SECONDS)
|
|
107
|
+
write_work_hash(pipe, work_snapshot)
|
|
108
|
+
drain_signals(pipe)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def beat_hash_args(busy)
|
|
112
|
+
[
|
|
113
|
+
'info', Wurk.dump_json(info_hash),
|
|
114
|
+
'concurrency', total_concurrency.to_s,
|
|
115
|
+
'busy', busy.to_s,
|
|
116
|
+
'beat', Time.now.to_f.to_s,
|
|
117
|
+
'quiet', @quiet_proc.call.to_s,
|
|
118
|
+
'rss', memory_usage_kb.to_s,
|
|
119
|
+
'rtt_us', @rtt_us.to_s
|
|
120
|
+
]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def write_work_hash(pipe, work_snapshot)
|
|
124
|
+
work_key = "#{@identity}:work"
|
|
125
|
+
pipe.call('UNLINK', work_key)
|
|
126
|
+
return if work_snapshot.empty?
|
|
127
|
+
|
|
128
|
+
args = work_snapshot.flat_map { |tid, hash| [tid.to_s, Wurk.dump_json(hash)] }
|
|
129
|
+
pipe.call('HSET', work_key, *args)
|
|
130
|
+
pipe.call('EXPIRE', work_key, TTL_SECONDS)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# LPOP one entry per second of cadence so a flood of queued signals
|
|
134
|
+
# can't stall the beat; anything older drains on the next beat.
|
|
135
|
+
def drain_signals(pipe)
|
|
136
|
+
BEAT_PAUSE.times { pipe.call('LPOP', "#{@identity}-signals") }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def info_hash
|
|
140
|
+
caps = @config.capsules.each_value
|
|
141
|
+
{
|
|
142
|
+
'hostname' => hostname,
|
|
143
|
+
'started_at' => @started_at,
|
|
144
|
+
'pid' => ::Process.pid,
|
|
145
|
+
'tag' => @config[:tag] || default_tag,
|
|
146
|
+
'concurrency' => total_concurrency,
|
|
147
|
+
'capsules' => capsules_info,
|
|
148
|
+
'queues' => caps.flat_map(&:queues).uniq,
|
|
149
|
+
'weights' => caps.map(&:weights),
|
|
150
|
+
'labels' => Array(@config[:labels]),
|
|
151
|
+
'identity' => @identity,
|
|
152
|
+
'version' => Wurk::VERSION,
|
|
153
|
+
'embedded' => @embedded
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def capsules_info
|
|
158
|
+
@config.capsules.transform_values do |cap|
|
|
159
|
+
{ 'concurrency' => cap.concurrency, 'mode' => cap.mode.to_s, 'weights' => cap.weights }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def total_concurrency
|
|
164
|
+
@config.capsules.each_value.sum(&:concurrency)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Best-effort statsd snapshot per beat. Emits `sidekiq.busy` (this
|
|
168
|
+
# process's in-flight job count) and `sidekiq.queue.size` (per queue
|
|
169
|
+
# LLEN). Tagged with `process:<identity>` so multi-process emissions
|
|
170
|
+
# of the global `queue.size` are tag-distinguishable downstream — pick
|
|
171
|
+
# `max` across the process tag in your dashboard. No-op when
|
|
172
|
+
# `config.dogstatsd` is unset (Statsd.gauge short-circuits on nil
|
|
173
|
+
# client) so the LLEN pipeline is also skipped.
|
|
174
|
+
# Spec hint: docs/target/sidekiq-ent.md §5.2 names (`busy`, `queue.size`).
|
|
175
|
+
def emit_statsd_gauges
|
|
176
|
+
return unless Wurk::Metrics::Statsd.client
|
|
177
|
+
|
|
178
|
+
busy = Processor::WORK_STATE.size
|
|
179
|
+
Wurk::Metrics::Statsd.gauge('busy', busy, tags: ["process:#{@identity}"])
|
|
180
|
+
emit_queue_sizes
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
handle_exception(e, { context: 'heartbeat metrics' })
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def emit_queue_sizes
|
|
186
|
+
queues = @config.capsules.each_value.flat_map(&:queues).uniq
|
|
187
|
+
return if queues.empty?
|
|
188
|
+
|
|
189
|
+
sizes = redis { |conn| conn.pipelined { |pipe| queues.each { |q| pipe.call('LLEN', "queue:#{q}") } } }
|
|
190
|
+
queues.zip(sizes).each do |queue, size|
|
|
191
|
+
Wurk::Metrics::Statsd.gauge(
|
|
192
|
+
'queue.size', size,
|
|
193
|
+
tags: ["queue:#{queue}", "process:#{@identity}"]
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Linux first via /proc/self/statm[1] (resident pages × 4 KB);
|
|
199
|
+
# `ps` fallback for macOS/BSD test runners. Zero on failure — the
|
|
200
|
+
# dashboard shows "—" rather than crashing.
|
|
201
|
+
def memory_usage_kb
|
|
202
|
+
if ::File.exist?('/proc/self/statm')
|
|
203
|
+
::File.read('/proc/self/statm').split[1].to_i * 4
|
|
204
|
+
else
|
|
205
|
+
`ps -o rss= -p #{::Process.pid}`.to_i
|
|
206
|
+
end
|
|
207
|
+
rescue StandardError
|
|
208
|
+
0
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'job'
|
|
5
|
+
|
|
6
|
+
module Wurk
|
|
7
|
+
# Iterable jobs split long-running work into small, idempotent chunks.
|
|
8
|
+
# Override `#build_enumerator` (yielding `[item, new_cursor]` pairs) and
|
|
9
|
+
# `#each_iteration(item, *args)`; the framework drives the loop, persists
|
|
10
|
+
# the cursor, and resumes after interruption.
|
|
11
|
+
#
|
|
12
|
+
# Defining `#perform` on the including class is refused at `method_added`
|
|
13
|
+
# — IterableJob owns the run loop. User code overrides `#each_iteration`.
|
|
14
|
+
#
|
|
15
|
+
# State lives in the `it-<jid>` HASH (sidekiq-free.md §1.5):
|
|
16
|
+
#
|
|
17
|
+
# ex : execution count (int)
|
|
18
|
+
# c : cursor (JSON string)
|
|
19
|
+
# rt : runtime accumulated (float seconds)
|
|
20
|
+
# cancelled : timestamp (int) if cancelled
|
|
21
|
+
#
|
|
22
|
+
# Spec: docs/target/sidekiq-free.md §6.4.
|
|
23
|
+
module IterableJob # rubocop:disable Metrics/ModuleLength
|
|
24
|
+
# Alias to the canonical `Wurk::Job::Interrupted`. The exception lives on
|
|
25
|
+
# `Wurk::Job` so non-iterable code paths (manual `interrupted?` checks)
|
|
26
|
+
# can raise the same class; the interrupt-handler middleware rescues by
|
|
27
|
+
# the `Wurk::Job::Interrupted` name.
|
|
28
|
+
Interrupted = Wurk::Job::Interrupted
|
|
29
|
+
|
|
30
|
+
# Default expiry for an iteration state HASH while the job is running or
|
|
31
|
+
# awaiting resume. Refreshed on every checkpoint.
|
|
32
|
+
STATE_TTL = 30 * 86_400
|
|
33
|
+
|
|
34
|
+
# Cursor flush + cancellation poll cadence. Both share the timer so
|
|
35
|
+
# a long-running iteration that hits the 5-second mark checkpoints
|
|
36
|
+
# *and* checks for cross-process cancellation in the same tick.
|
|
37
|
+
STATE_FLUSH_INTERVAL = 5
|
|
38
|
+
|
|
39
|
+
# Shorter TTL applied once the state is marked cancelled. The HASH
|
|
40
|
+
# outlives `cancel!` long enough for live workers to observe the flag
|
|
41
|
+
# but is reaped well before the 30-day default would expire.
|
|
42
|
+
CANCELLATION_PERIOD = 3 * 86_400
|
|
43
|
+
|
|
44
|
+
# Class-level guard injected via singleton-class prepend so we can call
|
|
45
|
+
# `super` cleanly and stay compatible with anything else hooking
|
|
46
|
+
# `method_added`.
|
|
47
|
+
module MethodAddedGuard
|
|
48
|
+
def method_added(method_name)
|
|
49
|
+
if method_name == :perform
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"#{self} is an IterableJob; override #each_iteration instead of #perform"
|
|
52
|
+
end
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.included(base)
|
|
58
|
+
base.include(Wurk::Job)
|
|
59
|
+
base.singleton_class.prepend(MethodAddedGuard)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# User overrides — must return an Enumerator yielding `[item, new_cursor]`
|
|
63
|
+
# pairs. The cursor must round-trip through JSON.
|
|
64
|
+
def build_enumerator(*, cursor:)
|
|
65
|
+
_ = cursor
|
|
66
|
+
raise NotImplementedError, "#{self.class} must override #build_enumerator"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def each_iteration(*)
|
|
70
|
+
raise NotImplementedError, "#{self.class} must override #each_iteration"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- lifecycle hooks (no-op defaults; users override as needed) -----
|
|
74
|
+
|
|
75
|
+
def on_start; end
|
|
76
|
+
def on_resume; end
|
|
77
|
+
def on_stop; end
|
|
78
|
+
def on_cancel; end
|
|
79
|
+
def on_complete; end
|
|
80
|
+
|
|
81
|
+
def around_iteration
|
|
82
|
+
yield
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- iteration state accessors --------------------------------------
|
|
86
|
+
|
|
87
|
+
attr_reader :current_object
|
|
88
|
+
|
|
89
|
+
def arguments
|
|
90
|
+
@arguments ||= []
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def cursor
|
|
94
|
+
@cursor
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Mark this iteration cancelled. Sets the in-process flag immediately
|
|
98
|
+
# (so the next `cancelled?` check inside the run loop trips) and, when
|
|
99
|
+
# a jid is bound, writes the timestamp to the `it-<jid>` HASH so other
|
|
100
|
+
# processes observe it on their next 5-second poll.
|
|
101
|
+
#
|
|
102
|
+
# Returns the integer epoch-seconds timestamp written.
|
|
103
|
+
def cancel!
|
|
104
|
+
ts_ms = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
|
|
105
|
+
@cancelled_at ||= ts_ms
|
|
106
|
+
ts = ts_ms / 1000
|
|
107
|
+
persist_cancellation(ts)
|
|
108
|
+
ts
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# True once `cancel!` has been called locally, OR — for cross-process
|
|
112
|
+
# cancellation — once the `cancelled` field appears in the `it-<jid>`
|
|
113
|
+
# HASH. The remote check is rate-limited to once per `STATE_FLUSH_INTERVAL`
|
|
114
|
+
# to keep the hot loop cheap.
|
|
115
|
+
def cancelled?
|
|
116
|
+
return true if @cancelled_at
|
|
117
|
+
|
|
118
|
+
ts = poll_remote_cancellation
|
|
119
|
+
return false unless ts
|
|
120
|
+
|
|
121
|
+
@cancelled_at = ts * 1000
|
|
122
|
+
true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Redis HASH key holding iteration state for this job. Wire-compat
|
|
126
|
+
# with Sidekiq's `it-<jid>` schema (sidekiq-free.md §1.5).
|
|
127
|
+
def iteration_key
|
|
128
|
+
"it-#{jid}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Foundation run loop. Loads any persisted state, drives the enumerator,
|
|
132
|
+
# checkpoints every `STATE_FLUSH_INTERVAL`, and on interruption persists
|
|
133
|
+
# the final cursor before re-raising so the interrupt-handler middleware
|
|
134
|
+
# can re-push the job at the head of the queue.
|
|
135
|
+
def perform(*args)
|
|
136
|
+
reset_run_state(args)
|
|
137
|
+
load_state
|
|
138
|
+
fire_lifecycle_start
|
|
139
|
+
@executions += 1
|
|
140
|
+
|
|
141
|
+
run_iterations(args)
|
|
142
|
+
|
|
143
|
+
finalize_complete
|
|
144
|
+
rescue Interrupted
|
|
145
|
+
finalize_interrupted
|
|
146
|
+
raise
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def reset_run_state(args)
|
|
152
|
+
@cancelled_at = nil
|
|
153
|
+
@arguments = args
|
|
154
|
+
@last_cancel_poll_ms = nil
|
|
155
|
+
@last_flush_ms = nil
|
|
156
|
+
@run_started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
157
|
+
@executions = 0
|
|
158
|
+
@runtime_acc = 0.0
|
|
159
|
+
@cursor = nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def fire_lifecycle_start
|
|
163
|
+
if @executions.positive?
|
|
164
|
+
on_resume
|
|
165
|
+
else
|
|
166
|
+
on_start
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def run_iterations(args)
|
|
171
|
+
enum = build_enumerator(*args, cursor: @cursor)
|
|
172
|
+
enum.each do |item, new_cursor|
|
|
173
|
+
raise Interrupted if cancelled?
|
|
174
|
+
|
|
175
|
+
@current_object = item
|
|
176
|
+
around_iteration { each_iteration(item, *args) }
|
|
177
|
+
@cursor = new_cursor
|
|
178
|
+
maybe_flush_state
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def finalize_complete
|
|
183
|
+
flush_state(final: true)
|
|
184
|
+
on_complete
|
|
185
|
+
delete_state
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def finalize_interrupted
|
|
189
|
+
flush_state(final: true)
|
|
190
|
+
on_cancel if @cancelled_at
|
|
191
|
+
on_stop
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# --- persistence ----------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def load_state
|
|
197
|
+
return unless persistable?
|
|
198
|
+
|
|
199
|
+
hash = normalize_hgetall(redis_call('HGETALL', iteration_key))
|
|
200
|
+
return if hash.empty?
|
|
201
|
+
|
|
202
|
+
apply_loaded_state(hash)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def apply_loaded_state(hash)
|
|
206
|
+
@executions = hash['ex'].to_i if hash['ex']
|
|
207
|
+
@runtime_acc = hash['rt'].to_f if hash['rt']
|
|
208
|
+
@cursor = ::JSON.parse(hash['c']) if hash['c']
|
|
209
|
+
@cancelled_at = hash['cancelled'].to_i * 1000 if hash['cancelled']
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def maybe_flush_state
|
|
213
|
+
return unless persistable?
|
|
214
|
+
|
|
215
|
+
now_ms = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
|
|
216
|
+
@last_flush_ms ||= now_ms
|
|
217
|
+
return if now_ms - @last_flush_ms < STATE_FLUSH_INTERVAL * 1000
|
|
218
|
+
|
|
219
|
+
flush_state
|
|
220
|
+
@last_flush_ms = now_ms
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def flush_state(final: false)
|
|
224
|
+
return unless persistable?
|
|
225
|
+
|
|
226
|
+
runtime = @runtime_acc + (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - @run_started_at)
|
|
227
|
+
@runtime_acc = runtime if final
|
|
228
|
+
ttl = @cancelled_at ? CANCELLATION_PERIOD : STATE_TTL
|
|
229
|
+
redis_pool.with do |conn|
|
|
230
|
+
conn.pipelined do |pipe|
|
|
231
|
+
pipe.call('HSET', iteration_key,
|
|
232
|
+
'ex', @executions.to_s,
|
|
233
|
+
'c', ::JSON.generate(@cursor),
|
|
234
|
+
'rt', runtime.to_s)
|
|
235
|
+
pipe.call('EXPIRE', iteration_key, ttl)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def delete_state
|
|
241
|
+
return unless persistable?
|
|
242
|
+
|
|
243
|
+
redis_call('DEL', iteration_key)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def persist_cancellation(timestamp)
|
|
247
|
+
return unless persistable?
|
|
248
|
+
|
|
249
|
+
redis_pool.with do |conn|
|
|
250
|
+
conn.pipelined do |pipe|
|
|
251
|
+
pipe.call('HSET', iteration_key, 'cancelled', timestamp)
|
|
252
|
+
pipe.call('EXPIRE', iteration_key, CANCELLATION_PERIOD)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def poll_remote_cancellation
|
|
258
|
+
return nil unless persistable?
|
|
259
|
+
|
|
260
|
+
now_ms = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :millisecond)
|
|
261
|
+
return nil if @last_cancel_poll_ms && now_ms - @last_cancel_poll_ms < STATE_FLUSH_INTERVAL * 1000
|
|
262
|
+
|
|
263
|
+
@last_cancel_poll_ms = now_ms
|
|
264
|
+
raw = redis_call('HGET', iteration_key, 'cancelled')
|
|
265
|
+
raw && !raw.to_s.empty? ? raw.to_i : nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# --- helpers --------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def persistable?
|
|
271
|
+
!jid.nil? && !jid.to_s.empty?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def redis_pool
|
|
275
|
+
Wurk.redis_pool
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def redis_call(*args)
|
|
279
|
+
redis_pool.with { |conn| conn.call(*args) }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# redis-client returns HGETALL as a flat array on some adapters and as
|
|
283
|
+
# a Hash on others. Normalize to a Hash with String keys/values either way.
|
|
284
|
+
def normalize_hgetall(raw)
|
|
285
|
+
case raw
|
|
286
|
+
when Hash then raw
|
|
287
|
+
when Array then raw.each_slice(2).to_h
|
|
288
|
+
else {}
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
module Job
|
|
5
|
+
# Options-only slice of the Wurk::Worker DSL. Mixed into `ActiveJob::Base`
|
|
6
|
+
# by the wurk adapter so native AJ classes can configure Wurk/Sidekiq
|
|
7
|
+
# features (`sidekiq_options retry: 3`, `sidekiq_retry_in { ... }`, …)
|
|
8
|
+
# without including the full `perform_async`/`set` surface that doesn't
|
|
9
|
+
# apply to AJ.
|
|
10
|
+
#
|
|
11
|
+
# Aliased as `Sidekiq::Job::Options`. Wire-compat sacred — third-party
|
|
12
|
+
# gems that extend AJ via this module load unchanged.
|
|
13
|
+
#
|
|
14
|
+
# Spec: docs/target/sidekiq-free.md §6 (Sidekiq::Job::Options).
|
|
15
|
+
module Options
|
|
16
|
+
def self.included(base)
|
|
17
|
+
base.extend(ClassMethods)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module ClassMethods
|
|
21
|
+
def sidekiq_options(opts = {})
|
|
22
|
+
merged = get_sidekiq_options.merge(opts.transform_keys(&:to_s))
|
|
23
|
+
@sidekiq_options_hash = merged
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Sidekiq's public API name — must stay `get_sidekiq_options`.
|
|
27
|
+
def get_sidekiq_options # rubocop:disable Naming/AccessorMethodName
|
|
28
|
+
@sidekiq_options_hash ||= inherited_sidekiq_options # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sidekiq_options_hash
|
|
32
|
+
get_sidekiq_options
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :sidekiq_retry_in_block, :sidekiq_retries_exhausted_block
|
|
36
|
+
|
|
37
|
+
def sidekiq_retry_in(&block)
|
|
38
|
+
@sidekiq_retry_in_block = block
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sidekiq_retries_exhausted(&block)
|
|
42
|
+
@sidekiq_retries_exhausted_block = block
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inherited(subclass)
|
|
46
|
+
super
|
|
47
|
+
subclass.instance_variable_set(:@sidekiq_options_hash, get_sidekiq_options.dup)
|
|
48
|
+
inherit_ivar(subclass, :@sidekiq_retry_in_block)
|
|
49
|
+
inherit_ivar(subclass, :@sidekiq_retries_exhausted_block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def inherited_sidekiq_options
|
|
55
|
+
if superclass.respond_to?(:get_sidekiq_options)
|
|
56
|
+
superclass.get_sidekiq_options.dup
|
|
57
|
+
else
|
|
58
|
+
Wurk.default_job_options.dup
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def inherit_ivar(subclass, ivar)
|
|
63
|
+
return unless instance_variable_defined?(ivar)
|
|
64
|
+
|
|
65
|
+
subclass.instance_variable_set(ivar, instance_variable_get(ivar))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/wurk/job.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'worker'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
# Sidekiq 7+ alias for Wurk::Worker. `include Wurk::Job` and
|
|
7
|
+
# `include Sidekiq::Job` are the same surface.
|
|
8
|
+
#
|
|
9
|
+
# Instance-level `jid`, `_context`, `interrupted?`, and `logger` come
|
|
10
|
+
# from Wurk::Worker. Class-level DSL (`sidekiq_options`, `perform_*`,
|
|
11
|
+
# `set`, retry blocks) does too — Job is a pure alias module that
|
|
12
|
+
# re-exposes Worker under the modern name.
|
|
13
|
+
module Job
|
|
14
|
+
# Raised mid-iteration when the run loop must yield — either a swarm
|
|
15
|
+
# shutdown signal or a cooperative cancellation. The interrupt-handler
|
|
16
|
+
# middleware catches it, re-pushes the job, and raises
|
|
17
|
+
# `Wurk::JobRetry::Skip` so the retry layer skips error bookkeeping.
|
|
18
|
+
# User code must not rescue this.
|
|
19
|
+
#
|
|
20
|
+
# Spec: docs/target/sidekiq-free.md §6.4.
|
|
21
|
+
class Interrupted < RuntimeError; end
|
|
22
|
+
|
|
23
|
+
def self.included(base)
|
|
24
|
+
base.include(Wurk::Worker)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Mirror the module-level test helpers so `Sidekiq::Job.jobs /
|
|
28
|
+
# clear_all / drain_all` work the same as `Sidekiq::Worker.*`.
|
|
29
|
+
def self.jobs = Wurk::Worker.jobs
|
|
30
|
+
def self.clear_all = Wurk::Worker.clear_all
|
|
31
|
+
def self.drain_all = Wurk::Worker.drain_all
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wurk
|
|
4
|
+
# Wraps the per-job execution span. Logs "start"/"done"/"fail" at INFO,
|
|
5
|
+
# pushes :elapsed into Wurk::Context so the logger formatter can pick it
|
|
6
|
+
# up, and prepares the thread-local context hash (jid, class, plus
|
|
7
|
+
# config[:logged_job_attributes]).
|
|
8
|
+
#
|
|
9
|
+
# Two entry points, called from Processor#process in this order:
|
|
10
|
+
# 1. prepare(job_hash) { ... } → sets thread-local context, applies
|
|
11
|
+
# per-job log_level, yields to the rest of dispatch.
|
|
12
|
+
# 2. call(item, queue) { ... } → wraps the actual perform with the
|
|
13
|
+
# start/done/fail log line trio and the elapsed-ms measurement.
|
|
14
|
+
#
|
|
15
|
+
# Skipping default logging is controlled by config[:skip_default_job_logging];
|
|
16
|
+
# the prepare step still runs so context/log_level still apply.
|
|
17
|
+
#
|
|
18
|
+
# Spec: docs/target/sidekiq-free.md §18.
|
|
19
|
+
class JobLogger
|
|
20
|
+
def initialize(config)
|
|
21
|
+
@config = config
|
|
22
|
+
@logger = @config.logger
|
|
23
|
+
@skip = !!@config[:skip_default_job_logging]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(_item, _queue)
|
|
27
|
+
start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
28
|
+
@logger.info { 'start' } unless @skip
|
|
29
|
+
|
|
30
|
+
yield
|
|
31
|
+
|
|
32
|
+
Wurk::Context.add(:elapsed, elapsed(start))
|
|
33
|
+
@logger.info { 'done' } unless @skip
|
|
34
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
35
|
+
Wurk::Context.add(:elapsed, elapsed(start))
|
|
36
|
+
@logger.info { 'fail' } unless @skip
|
|
37
|
+
raise
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sets thread-local context for the duration of `block`, optionally
|
|
41
|
+
# under a per-job log level. ActiveJob-wrapped jobs expose the real
|
|
42
|
+
# class via the "wrapped" key — log that, not the wrapper.
|
|
43
|
+
def prepare(job_hash, &block)
|
|
44
|
+
h = {
|
|
45
|
+
jid: job_hash['jid'],
|
|
46
|
+
class: job_hash['wrapped'] || job_hash['class']
|
|
47
|
+
}
|
|
48
|
+
@config[:logged_job_attributes].each do |attr|
|
|
49
|
+
h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
level = job_hash['log_level']
|
|
53
|
+
Wurk::Context.with(h) do
|
|
54
|
+
if level
|
|
55
|
+
@logger.with_level(level, &block)
|
|
56
|
+
else
|
|
57
|
+
yield
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def elapsed(start)
|
|
65
|
+
(::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start).round(3)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|