milvaion-worker 1.0.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 +7 -0
- data/README.md +190 -0
- data/lib/milvaion/cancellation_listener.rb +136 -0
- data/lib/milvaion/context.rb +191 -0
- data/lib/milvaion/errors.rb +45 -0
- data/lib/milvaion/job.rb +43 -0
- data/lib/milvaion/log_buffer.rb +120 -0
- data/lib/milvaion/metrics.rb +115 -0
- data/lib/milvaion/options.rb +205 -0
- data/lib/milvaion/processor.rb +558 -0
- data/lib/milvaion/protocol.rb +45 -0
- data/lib/milvaion/thread_pool.rb +85 -0
- data/lib/milvaion/worker/version.rb +8 -0
- data/lib/milvaion/worker.rb +504 -0
- data/lib/milvaion-worker.rb +3 -0
- data/lib/milvaion.rb +3 -0
- data/milvaion-worker.gemspec +29 -0
- metadata +118 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "worker/version"
|
|
9
|
+
require_relative "protocol"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "options"
|
|
12
|
+
require_relative "context"
|
|
13
|
+
require_relative "log_buffer"
|
|
14
|
+
require_relative "processor"
|
|
15
|
+
require_relative "thread_pool"
|
|
16
|
+
require_relative "metrics"
|
|
17
|
+
require_relative "job"
|
|
18
|
+
require_relative "cancellation_listener"
|
|
19
|
+
|
|
20
|
+
module Milvaion
|
|
21
|
+
# A Milvaion worker. Register jobs with #job, then call #run (blocking) or #start / #stop.
|
|
22
|
+
#
|
|
23
|
+
# worker = Milvaion::Worker.new(worker_id: "report-worker")
|
|
24
|
+
# worker.job("SendEmailJob") { |ctx| ctx.log("hi"); { sent: true } }
|
|
25
|
+
# worker.run
|
|
26
|
+
class Worker
|
|
27
|
+
attr_reader :options
|
|
28
|
+
|
|
29
|
+
def initialize(options = nil, **kwargs)
|
|
30
|
+
@options = options || WorkerOptions.new(**kwargs)
|
|
31
|
+
@jobs = {}
|
|
32
|
+
@cancellations = CancellationRegistry.new
|
|
33
|
+
@sampler = ResourceSampler.new
|
|
34
|
+
@publisher = AmqpPublisher.new
|
|
35
|
+
@consumers = []
|
|
36
|
+
@state = :idle
|
|
37
|
+
@state_mutex = Mutex.new
|
|
38
|
+
@heartbeat_mutex = Mutex.new
|
|
39
|
+
@heartbeat_changed = ConditionVariable.new
|
|
40
|
+
@heartbeat_requested = false
|
|
41
|
+
@heartbeat_stopped = false
|
|
42
|
+
@last_immediate = 0.0
|
|
43
|
+
@stop_thread = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def logger
|
|
47
|
+
options.logger
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def worker_id
|
|
51
|
+
options.worker_id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def instance_id
|
|
55
|
+
options.instance_id
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def state
|
|
59
|
+
@state_mutex.synchronize { @state }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def running_jobs
|
|
63
|
+
@processor ? @processor.running : 0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Registers a job. Either a name and a block (or any object that responds to call):
|
|
67
|
+
#
|
|
68
|
+
# worker.job("SendEmailJob", max_retries: 5) { |ctx| ... }
|
|
69
|
+
#
|
|
70
|
+
# or a Milvaion::Job subclass, optionally with options that override its job_options:
|
|
71
|
+
#
|
|
72
|
+
# worker.job(SendEmailJob)
|
|
73
|
+
#
|
|
74
|
+
# Names are unique within a worker and matched case-insensitively.
|
|
75
|
+
def job(name_or_class, handler = nil, **options, &block)
|
|
76
|
+
if name_or_class.is_a?(Class)
|
|
77
|
+
klass = name_or_class
|
|
78
|
+
raise ArgumentError, "#{klass} must define perform(ctx)" unless klass.method_defined?(:perform)
|
|
79
|
+
|
|
80
|
+
name = klass.respond_to?(:job_name) ? klass.job_name : klass.name.to_s.split("::").last
|
|
81
|
+
defaults = klass.respond_to?(:job_options) ? klass.job_options : {}
|
|
82
|
+
|
|
83
|
+
return add_job(name, klass, JobOptions.new(**defaults, **options))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
callable = handler || block
|
|
87
|
+
raise ArgumentError, "Pass a block or a handler that responds to call." unless callable.respond_to?(:call)
|
|
88
|
+
|
|
89
|
+
add_job(name_or_class, callable, JobOptions.new(**options))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Registers a handler (anything that responds to call(ctx), or a Job class) under the name the
|
|
93
|
+
# dashboard dispatches the job by (JobNameInWorker).
|
|
94
|
+
def add_job(name, handler, job_options = JobOptions.new)
|
|
95
|
+
raise Error, "Jobs must be added before the worker starts." unless state == :idle
|
|
96
|
+
|
|
97
|
+
name = name.to_s.strip
|
|
98
|
+
raise ArgumentError, "Job name is required." if name.empty?
|
|
99
|
+
|
|
100
|
+
if @jobs.key?(name.downcase)
|
|
101
|
+
raise ArgumentError, "Job #{name.inspect} is already registered. Job names must be unique within a worker (case-insensitive)."
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@jobs[name.downcase] = Milvaion.resolve_job(name, handler, job_options, options)
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Starts the worker and blocks until SIGINT or SIGTERM, then shuts down gracefully.
|
|
109
|
+
def run
|
|
110
|
+
reader, writer = IO.pipe
|
|
111
|
+
previous = {}
|
|
112
|
+
|
|
113
|
+
%w[INT TERM].each do |signal|
|
|
114
|
+
previous[signal] = Signal.trap(signal) do
|
|
115
|
+
writer.write_nonblock(".")
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
begin
|
|
122
|
+
start
|
|
123
|
+
reader.read(1)
|
|
124
|
+
logger.info("Shutdown signal received.")
|
|
125
|
+
ensure
|
|
126
|
+
stop
|
|
127
|
+
previous.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
|
|
128
|
+
reader.close
|
|
129
|
+
writer.close
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Connects, registers this instance and starts consuming. Raises if RabbitMQ or Redis cannot be
|
|
134
|
+
# reached within startup_timeout.
|
|
135
|
+
def start
|
|
136
|
+
@state_mutex.synchronize do
|
|
137
|
+
raise Error, "Worker cannot be started from state #{@state}." unless @state == :idle
|
|
138
|
+
raise Error, "Add at least one job before starting the worker." if @jobs.empty?
|
|
139
|
+
|
|
140
|
+
@state = :starting
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
logger.info("Starting worker #{worker_id} (instance #{instance_id}) with #{@jobs.size} job type(s).")
|
|
144
|
+
|
|
145
|
+
@logs = LogBuffer.new(->(batch) { send_logs(batch) }, logger).start
|
|
146
|
+
@processor = Processor.new(options, @jobs, @publisher, @logs, @cancellations)
|
|
147
|
+
@processor.on_count_changed = -> { request_immediate_heartbeat }
|
|
148
|
+
@pool = ThreadPool.new(options.max_parallel_jobs, logger).start
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
if options.redis_url
|
|
152
|
+
@listener = CancellationListener.new(options.redis_url, options.cancellation_channel, @cancellations, logger)
|
|
153
|
+
@listener.start(options.startup_timeout)
|
|
154
|
+
else
|
|
155
|
+
logger.warn("Redis is disabled: jobs cannot be cancelled from the dashboard.")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
connect
|
|
159
|
+
rescue Exception # rubocop:disable Lint/RescueException -- clean up on any failure, then re-raise
|
|
160
|
+
set_state(:stopped)
|
|
161
|
+
close
|
|
162
|
+
raise
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
@heartbeat_thread = Thread.new do
|
|
166
|
+
Thread.current.name = "milvaion-heartbeat"
|
|
167
|
+
heartbeat_loop
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
set_state(:running)
|
|
171
|
+
self
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Stops taking new jobs, lets running ones finish for up to shutdown_timeout, returns the rest to
|
|
175
|
+
# the queue and disconnects. Safe to call more than once.
|
|
176
|
+
def stop
|
|
177
|
+
thread = @state_mutex.synchronize do
|
|
178
|
+
return if %i[idle stopped].include?(@state)
|
|
179
|
+
|
|
180
|
+
@stop_thread ||= Thread.new { shutdown }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
thread.join
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The registration message. Public for tests.
|
|
188
|
+
def build_registration
|
|
189
|
+
jobs = @jobs.values
|
|
190
|
+
schema = ->(value) { value.is_a?(String) ? value : JSON.generate(value) }
|
|
191
|
+
|
|
192
|
+
metadata = {
|
|
193
|
+
"IsExternal" => false,
|
|
194
|
+
"ExternalScheduler" => nil,
|
|
195
|
+
"ProcessorCount" => Etc.nprocessors,
|
|
196
|
+
"OSVersion" => os_version,
|
|
197
|
+
"RuntimeVersion" => "Ruby #{RUBY_VERSION}",
|
|
198
|
+
"HeartbeatInterval" => options.heartbeat_interval.to_i,
|
|
199
|
+
"Sdk" => "ruby",
|
|
200
|
+
"SdkVersion" => VERSION,
|
|
201
|
+
"JobConfigs" => jobs.map do |j|
|
|
202
|
+
{ "JobType" => j.name, "ConsumerId" => j.consumer_id, "MaxParallelJobs" => j.max_parallel_jobs, "ExecutionTimeoutSeconds" => j.execution_timeout.to_i }
|
|
203
|
+
end
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
"workerId" => worker_id,
|
|
208
|
+
"instanceId" => instance_id,
|
|
209
|
+
"displayName" => "#{worker_id} (#{instance_id})",
|
|
210
|
+
"hostName" => Socket.gethostname,
|
|
211
|
+
"ipAddress" => ResourceSampler.local_ip_address,
|
|
212
|
+
"routingPatterns" => jobs.to_h { |j| [j.name, j.routing_pattern] },
|
|
213
|
+
"jobDataDefinitions" => jobs.reject { |j| j.data_schema.nil? }.to_h { |j| [j.name, schema.call(j.data_schema)] },
|
|
214
|
+
"jobResultDefinitions" => jobs.reject { |j| j.result_schema.nil? }.to_h { |j| [j.name, schema.call(j.result_schema)] },
|
|
215
|
+
"jobTypes" => jobs.map(&:name),
|
|
216
|
+
"maxParallelJobs" => options.max_parallel_jobs,
|
|
217
|
+
"version" => options.version,
|
|
218
|
+
# The API reads this case-sensitively, so its properties are PascalCase.
|
|
219
|
+
"metadata" => JSON.generate(metadata)
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def set_state(value)
|
|
226
|
+
@state_mutex.synchronize { @state = value }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def monotonic
|
|
230
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def os_version
|
|
234
|
+
uname = Etc.uname
|
|
235
|
+
"#{uname[:sysname]} #{uname[:release]}"
|
|
236
|
+
rescue StandardError
|
|
237
|
+
RbConfig::CONFIG["host_os"]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def connect
|
|
241
|
+
require "bunny"
|
|
242
|
+
|
|
243
|
+
deadline = monotonic + options.startup_timeout
|
|
244
|
+
|
|
245
|
+
begin
|
|
246
|
+
connection = Bunny.new(
|
|
247
|
+
options.rabbitmq_url,
|
|
248
|
+
heartbeat: 60,
|
|
249
|
+
connection_timeout: 10,
|
|
250
|
+
automatically_recover: true,
|
|
251
|
+
network_recovery_interval: 2,
|
|
252
|
+
recovery_attempts: nil,
|
|
253
|
+
recover_from_connection_close: true,
|
|
254
|
+
connection_name: instance_id,
|
|
255
|
+
logger: logger
|
|
256
|
+
)
|
|
257
|
+
connection.start
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
raise ConnectionError, "Could not connect to RabbitMQ within #{options.startup_timeout}s: #{e.message}" if monotonic + 2 > deadline
|
|
260
|
+
|
|
261
|
+
logger.warn("Could not connect to RabbitMQ: #{e.message}")
|
|
262
|
+
Kernel.sleep(2)
|
|
263
|
+
retry
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
@connection = connection
|
|
267
|
+
# Bunny restores channels, queues, bindings and consumers itself; the API also needs a fresh registration.
|
|
268
|
+
connection.after_recovery_completed { Thread.new { on_reconnect } }
|
|
269
|
+
|
|
270
|
+
setup(connection)
|
|
271
|
+
logger.info("Connected to RabbitMQ.")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def setup(connection)
|
|
275
|
+
queue_args = options.queue_type == "quorum" ? { "x-queue-type" => "quorum" } : {}
|
|
276
|
+
channel = connection.create_channel
|
|
277
|
+
channel.confirm_select
|
|
278
|
+
|
|
279
|
+
[Protocol::QUEUE_STATUS_UPDATES, Protocol::QUEUE_WORKER_LOGS, Protocol::QUEUE_WORKER_HEARTBEAT, Protocol::QUEUE_WORKER_REGISTRATION].each do |queue|
|
|
280
|
+
channel.queue(queue, durable: true, arguments: queue_args)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
channel.topic(Protocol::EXCHANGE_JOBS, durable: true)
|
|
284
|
+
dead_letter = channel.direct(Protocol::EXCHANGE_DEAD_LETTER, durable: true)
|
|
285
|
+
channel.queue(Protocol::QUEUE_FAILED_OCCURRENCES, durable: true, arguments: queue_args)
|
|
286
|
+
.bind(dead_letter, routing_key: Protocol::DEAD_LETTER_ROUTING_KEY)
|
|
287
|
+
|
|
288
|
+
# Before consuming, so the first delivery can already report its status.
|
|
289
|
+
@publisher.attach(channel, { Protocol::EXCHANGE_DEAD_LETTER => dead_letter })
|
|
290
|
+
|
|
291
|
+
@jobs.each_value do |job|
|
|
292
|
+
consumer_channel = connection.create_channel(nil, 1)
|
|
293
|
+
consumer_channel.prefetch(job.max_parallel_jobs)
|
|
294
|
+
settle_lock = Mutex.new
|
|
295
|
+
|
|
296
|
+
arguments = { "x-dead-letter-exchange" => Protocol::EXCHANGE_DEAD_LETTER, "x-dead-letter-routing-key" => Protocol::DEAD_LETTER_ROUTING_KEY }.merge(queue_args)
|
|
297
|
+
queue = consumer_channel.queue(job.queue, durable: true, arguments: arguments)
|
|
298
|
+
queue.bind(Protocol::EXCHANGE_JOBS, routing_key: job.routing_pattern)
|
|
299
|
+
|
|
300
|
+
consumer = queue.subscribe(manual_ack: true, block: false, consumer_tag: "#{instance_id}.#{job.consumer_id}") do |info, properties, body|
|
|
301
|
+
on_delivery(job, consumer_channel, settle_lock, info, properties, body)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
@consumers << consumer
|
|
305
|
+
logger.info("Consuming #{job.name} from #{job.queue} (pattern #{job.routing_pattern}, prefetch #{job.max_parallel_jobs}).")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
register
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def on_delivery(job, channel, settle_lock, info, properties, body)
|
|
312
|
+
tag = info.delivery_tag
|
|
313
|
+
|
|
314
|
+
delivery = Delivery.new(
|
|
315
|
+
body: body,
|
|
316
|
+
headers: properties.headers || {},
|
|
317
|
+
correlation_id: properties.correlation_id,
|
|
318
|
+
redelivered: info.redelivered? ? true : false,
|
|
319
|
+
ack: -> { settle_lock.synchronize { channel.ack(tag) } },
|
|
320
|
+
nack: ->(requeue) { settle_lock.synchronize { channel.nack(tag, false, requeue) } }
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return if @pool.post(delivery) { |item| @processor.handle(item, job) }
|
|
324
|
+
|
|
325
|
+
# The worker is stopping: give the message back.
|
|
326
|
+
begin
|
|
327
|
+
delivery.nack.call(true)
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
logger.debug("Could not return a message to the queue: #{e.message}")
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def on_reconnect
|
|
334
|
+
logger.info("Reconnected to RabbitMQ.")
|
|
335
|
+
register
|
|
336
|
+
rescue StandardError => e
|
|
337
|
+
logger.warn("Re-registering after a reconnect failed: #{e.message}")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def register
|
|
341
|
+
@publisher.publish("", Protocol::QUEUE_WORKER_REGISTRATION, JSON.generate(build_registration), persistent: true)
|
|
342
|
+
send_heartbeat(false)
|
|
343
|
+
logger.info("Registered #{worker_id} (instance #{instance_id}).")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def heartbeat_loop
|
|
347
|
+
interval = options.heartbeat_interval.to_f
|
|
348
|
+
next_regular = monotonic + interval
|
|
349
|
+
|
|
350
|
+
loop do
|
|
351
|
+
@heartbeat_mutex.synchronize do
|
|
352
|
+
loop do
|
|
353
|
+
return if @heartbeat_stopped
|
|
354
|
+
|
|
355
|
+
now = monotonic
|
|
356
|
+
|
|
357
|
+
if @heartbeat_requested && now >= @last_immediate + 1
|
|
358
|
+
@heartbeat_requested = false
|
|
359
|
+
@last_immediate = now
|
|
360
|
+
break
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
if now >= next_regular
|
|
364
|
+
next_regular = now + interval
|
|
365
|
+
break
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
wake = [next_regular, (@last_immediate + 1 if @heartbeat_requested)].compact.min
|
|
369
|
+
@heartbeat_changed.wait(@heartbeat_mutex, wake - now)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
send_heartbeat(false)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Reports a changed job count at most once a second; the last change is always sent.
|
|
378
|
+
def request_immediate_heartbeat
|
|
379
|
+
return unless %i[running stopping].include?(state)
|
|
380
|
+
|
|
381
|
+
@heartbeat_mutex.synchronize do
|
|
382
|
+
@heartbeat_requested = true
|
|
383
|
+
@heartbeat_changed.signal
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def send_heartbeat(stopping)
|
|
388
|
+
body = JSON.generate({
|
|
389
|
+
"workerId" => worker_id,
|
|
390
|
+
"instanceId" => instance_id,
|
|
391
|
+
"currentJobs" => stopping ? 0 : running_jobs,
|
|
392
|
+
"memoryBytes" => @sampler.memory_bytes,
|
|
393
|
+
"cpuUsagePercent" => @sampler.cpu_usage_percent,
|
|
394
|
+
"timestamp" => Milvaion.utc_now,
|
|
395
|
+
"isStopping" => stopping
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
@publisher.publish("", Protocol::QUEUE_WORKER_HEARTBEAT, body, persistent: false)
|
|
399
|
+
rescue StandardError => e
|
|
400
|
+
logger.debug("Heartbeat failed; will retry on the next interval: #{e.message}")
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def send_logs(batch)
|
|
404
|
+
@publisher.publish("", Protocol::QUEUE_WORKER_LOGS, JSON.generate(batch), persistent: true)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def shutdown
|
|
408
|
+
set_state(:stopping)
|
|
409
|
+
logger.info("Stopping worker instance #{instance_id}...")
|
|
410
|
+
|
|
411
|
+
@consumers.each do |consumer|
|
|
412
|
+
consumer.cancel
|
|
413
|
+
rescue StandardError
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
416
|
+
@consumers.clear
|
|
417
|
+
|
|
418
|
+
# Deliveries waiting for a pool thread have not started: give them straight back.
|
|
419
|
+
@pool.close.each do |delivery|
|
|
420
|
+
delivery.nack.call(true)
|
|
421
|
+
rescue StandardError
|
|
422
|
+
nil
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
@processor.begin_shutdown
|
|
426
|
+
|
|
427
|
+
unless @processor.wait_for_idle(options.shutdown_timeout)
|
|
428
|
+
logger.warn("#{@processor.running} job(s) still running after #{options.shutdown_timeout}s. Returning them to the queue.")
|
|
429
|
+
@cancellations.abort_all(AbortReason::SHUTDOWN, "Worker is shutting down.")
|
|
430
|
+
@processor.wait_for_idle(10)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
@heartbeat_mutex.synchronize do
|
|
434
|
+
@heartbeat_stopped = true
|
|
435
|
+
@heartbeat_changed.broadcast
|
|
436
|
+
end
|
|
437
|
+
@heartbeat_thread&.join(5)
|
|
438
|
+
|
|
439
|
+
send_heartbeat(true)
|
|
440
|
+
|
|
441
|
+
begin
|
|
442
|
+
@logs.flush
|
|
443
|
+
rescue StandardError => e
|
|
444
|
+
logger.warn("Some job logs could not be sent before shutdown: #{e.message}")
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
close
|
|
448
|
+
set_state(:stopped)
|
|
449
|
+
logger.info("Worker instance #{instance_id} stopped.")
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def close
|
|
453
|
+
@listener&.stop
|
|
454
|
+
@logs&.close
|
|
455
|
+
@publisher.detach
|
|
456
|
+
@pool&.close
|
|
457
|
+
@pool&.join(5)
|
|
458
|
+
|
|
459
|
+
begin
|
|
460
|
+
@connection&.close
|
|
461
|
+
rescue StandardError
|
|
462
|
+
nil
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Publishes on the confirm channel and waits for the broker's confirmation. Thread-safe.
|
|
468
|
+
class AmqpPublisher
|
|
469
|
+
def initialize
|
|
470
|
+
@mutex = Mutex.new
|
|
471
|
+
@channel = nil
|
|
472
|
+
@exchanges = {}
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def attach(channel, exchanges)
|
|
476
|
+
@mutex.synchronize do
|
|
477
|
+
@channel = channel
|
|
478
|
+
@exchanges = exchanges
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def detach
|
|
483
|
+
@mutex.synchronize do
|
|
484
|
+
@channel = nil
|
|
485
|
+
@exchanges = {}
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def publish(exchange, routing_key, body, persistent: true, correlation_id: nil, headers: nil)
|
|
490
|
+
@mutex.synchronize do
|
|
491
|
+
channel = @channel
|
|
492
|
+
raise ConnectionError, "Not connected to RabbitMQ." if channel.nil? || !channel.open?
|
|
493
|
+
|
|
494
|
+
target = exchange.empty? ? channel.default_exchange : @exchanges.fetch(exchange) { channel.exchange(exchange, no_declare: true) }
|
|
495
|
+
properties = { routing_key: routing_key, persistent: persistent, content_type: "application/json" }
|
|
496
|
+
properties[:correlation_id] = correlation_id if correlation_id
|
|
497
|
+
properties[:headers] = headers if headers
|
|
498
|
+
|
|
499
|
+
target.publish(body, **properties)
|
|
500
|
+
raise ConnectionError, "RabbitMQ did not confirm the message to #{routing_key}." unless channel.wait_for_confirms
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
end
|
data/lib/milvaion.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "milvaion-worker"
|
|
5
|
+
# Kept equal to Milvaion::Worker::VERSION by a spec; the publish workflow checks it against the tag.
|
|
6
|
+
spec.version = "1.0.0"
|
|
7
|
+
spec.authors = ["Milvasoft"]
|
|
8
|
+
spec.summary = "Ruby worker SDK for the Milvaion distributed job scheduler."
|
|
9
|
+
spec.description = "Run Milvaion jobs in Ruby: registration, heartbeats, live logs, retries with " \
|
|
10
|
+
"exponential backoff, dead lettering, timeouts, cancellation and graceful shutdown " \
|
|
11
|
+
"over RabbitMQ and Redis."
|
|
12
|
+
spec.homepage = "https://github.com/Milvasoft/milvaion"
|
|
13
|
+
spec.license = "Apache-2.0"
|
|
14
|
+
spec.required_ruby_version = ">= 3.1"
|
|
15
|
+
|
|
16
|
+
spec.metadata = {
|
|
17
|
+
"homepage_uri" => spec.homepage,
|
|
18
|
+
"source_code_uri" => "https://github.com/Milvasoft/milvaion/tree/master/sdks/ruby/milvaion-worker",
|
|
19
|
+
"documentation_uri" => "https://github.com/Milvasoft/milvaion/blob/master/sdks/ruby/milvaion-worker/README.md",
|
|
20
|
+
"bug_tracker_uri" => "https://github.com/Milvasoft/milvaion/issues"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
spec.files = Dir["lib/**/*.rb", "README.md", "milvaion-worker.gemspec"]
|
|
24
|
+
spec.require_paths = ["lib"]
|
|
25
|
+
|
|
26
|
+
spec.add_dependency "bunny", ">= 2.22", "< 3"
|
|
27
|
+
spec.add_dependency "logger", ">= 1.4"
|
|
28
|
+
spec.add_dependency "redis", ">= 4.8", "< 6"
|
|
29
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: milvaion-worker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Milvasoft
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: bunny
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.22'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '3'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '2.22'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: logger
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.4'
|
|
40
|
+
type: :runtime
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.4'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: redis
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '4.8'
|
|
54
|
+
- - "<"
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '6'
|
|
57
|
+
type: :runtime
|
|
58
|
+
prerelease: false
|
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '4.8'
|
|
64
|
+
- - "<"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '6'
|
|
67
|
+
description: 'Run Milvaion jobs in Ruby: registration, heartbeats, live logs, retries
|
|
68
|
+
with exponential backoff, dead lettering, timeouts, cancellation and graceful shutdown
|
|
69
|
+
over RabbitMQ and Redis.'
|
|
70
|
+
email:
|
|
71
|
+
executables: []
|
|
72
|
+
extensions: []
|
|
73
|
+
extra_rdoc_files: []
|
|
74
|
+
files:
|
|
75
|
+
- README.md
|
|
76
|
+
- lib/milvaion-worker.rb
|
|
77
|
+
- lib/milvaion.rb
|
|
78
|
+
- lib/milvaion/cancellation_listener.rb
|
|
79
|
+
- lib/milvaion/context.rb
|
|
80
|
+
- lib/milvaion/errors.rb
|
|
81
|
+
- lib/milvaion/job.rb
|
|
82
|
+
- lib/milvaion/log_buffer.rb
|
|
83
|
+
- lib/milvaion/metrics.rb
|
|
84
|
+
- lib/milvaion/options.rb
|
|
85
|
+
- lib/milvaion/processor.rb
|
|
86
|
+
- lib/milvaion/protocol.rb
|
|
87
|
+
- lib/milvaion/thread_pool.rb
|
|
88
|
+
- lib/milvaion/worker.rb
|
|
89
|
+
- lib/milvaion/worker/version.rb
|
|
90
|
+
- milvaion-worker.gemspec
|
|
91
|
+
homepage: https://github.com/Milvasoft/milvaion
|
|
92
|
+
licenses:
|
|
93
|
+
- Apache-2.0
|
|
94
|
+
metadata:
|
|
95
|
+
homepage_uri: https://github.com/Milvasoft/milvaion
|
|
96
|
+
source_code_uri: https://github.com/Milvasoft/milvaion/tree/master/sdks/ruby/milvaion-worker
|
|
97
|
+
documentation_uri: https://github.com/Milvasoft/milvaion/blob/master/sdks/ruby/milvaion-worker/README.md
|
|
98
|
+
bug_tracker_uri: https://github.com/Milvasoft/milvaion/issues
|
|
99
|
+
post_install_message:
|
|
100
|
+
rdoc_options: []
|
|
101
|
+
require_paths:
|
|
102
|
+
- lib
|
|
103
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '3.1'
|
|
108
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '0'
|
|
113
|
+
requirements: []
|
|
114
|
+
rubygems_version: 3.5.22
|
|
115
|
+
signing_key:
|
|
116
|
+
specification_version: 4
|
|
117
|
+
summary: Ruby worker SDK for the Milvaion distributed job scheduler.
|
|
118
|
+
test_files: []
|