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,558 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Milvaion
|
|
6
|
+
# One message taken from a job queue. ack is called with no arguments, nack with the requeue flag.
|
|
7
|
+
Delivery = Struct.new(:body, :headers, :correlation_id, :redelivered, :ack, :nack, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
# Abort signals of the jobs running on this instance, keyed by occurrence id.
|
|
10
|
+
class CancellationRegistry
|
|
11
|
+
def initialize
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@running = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def register(occurrence_id, abort)
|
|
17
|
+
@mutex.synchronize { @running[occurrence_id.downcase] = abort }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def unregister(occurrence_id)
|
|
21
|
+
@mutex.synchronize { @running.delete(occurrence_id.downcase) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns false when the job is not running on this instance.
|
|
25
|
+
def cancel(occurrence_id, reason = nil)
|
|
26
|
+
abort = @mutex.synchronize { @running[occurrence_id.to_s.downcase] }
|
|
27
|
+
return false if abort.nil?
|
|
28
|
+
|
|
29
|
+
abort.trigger(AbortReason::CANCELLED, reason.to_s.empty? ? "Job was cancelled." : "Job was cancelled: #{reason}")
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def abort_all(reason, message)
|
|
34
|
+
@mutex.synchronize { @running.values.dup }.each { |abort| abort.trigger(reason, message) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Runs job deliveries and reports their progress to the API, following the worker protocol.
|
|
39
|
+
# #handle runs synchronously on the calling thread (a worker pool thread); the handler itself runs
|
|
40
|
+
# on a thread of its own so that a timeout or cancellation can settle the occurrence right away.
|
|
41
|
+
class Processor
|
|
42
|
+
GUID = /\A\h{8}-?\h{4}-?\h{4}-?\h{4}-?\h{12}\z/.freeze
|
|
43
|
+
STATUS_RETRY_DELAYS = [0.5, 2, 5].freeze
|
|
44
|
+
|
|
45
|
+
Outcome = Struct.new(:result, :error, :abort, :abort_message, keyword_init: true)
|
|
46
|
+
|
|
47
|
+
attr_reader :options, :jobs, :publisher, :logs, :cancellations, :logger
|
|
48
|
+
# Called whenever the number of running jobs changes.
|
|
49
|
+
attr_accessor :on_count_changed
|
|
50
|
+
# Sleeps between attempts to publish a status. Replaceable in tests.
|
|
51
|
+
attr_accessor :status_sleep
|
|
52
|
+
# Waits out a retry delay; returns true if the worker started stopping meanwhile. Replaceable in tests.
|
|
53
|
+
attr_accessor :retry_wait
|
|
54
|
+
|
|
55
|
+
def initialize(options, jobs, publisher, logs, cancellations)
|
|
56
|
+
@options = options
|
|
57
|
+
@jobs = jobs
|
|
58
|
+
@publisher = publisher
|
|
59
|
+
@logs = logs
|
|
60
|
+
@cancellations = cancellations
|
|
61
|
+
@logger = options.logger
|
|
62
|
+
@shutdown = AbortSignal.new
|
|
63
|
+
@count_mutex = Mutex.new
|
|
64
|
+
@idle = ConditionVariable.new
|
|
65
|
+
@running = 0
|
|
66
|
+
@status_sleep = ->(seconds) { Kernel.sleep(seconds) }
|
|
67
|
+
@retry_wait = ->(seconds) { @shutdown.wait(seconds) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def running
|
|
71
|
+
@count_mutex.synchronize { @running }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stopping?
|
|
75
|
+
@shutdown.aborted?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Makes jobs waiting out a retry delay give their message back right away.
|
|
79
|
+
def begin_shutdown
|
|
80
|
+
@shutdown.trigger(AbortReason::SHUTDOWN, "Worker is shutting down.")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# True once no job is running, false after timeout seconds.
|
|
84
|
+
def wait_for_idle(timeout)
|
|
85
|
+
deadline = monotonic + timeout.to_f
|
|
86
|
+
|
|
87
|
+
@count_mutex.synchronize do
|
|
88
|
+
while @running.positive?
|
|
89
|
+
remaining = deadline - monotonic
|
|
90
|
+
return false if remaining <= 0
|
|
91
|
+
|
|
92
|
+
@idle.wait(@count_mutex, remaining)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle(delivery, consumer)
|
|
100
|
+
change_count(1)
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
process(delivery, consumer)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
logger.error("Unexpected error while processing a job delivery: #{e.class}: #{e.message}")
|
|
106
|
+
settle { delivery.nack.call(false) }
|
|
107
|
+
ensure
|
|
108
|
+
change_count(-1)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The parsed job, or nil when the body is not a scheduled job.
|
|
113
|
+
def self.parse_job(body)
|
|
114
|
+
job = JSON.parse(body.to_s)
|
|
115
|
+
return nil unless job.is_a?(Hash) && job["id"].is_a?(String) && job["jobNameInWorker"].is_a?(String)
|
|
116
|
+
|
|
117
|
+
job
|
|
118
|
+
rescue JSON::ParserError, EncodingError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A header value as a string. RabbitMQ delivers strings as binary.
|
|
123
|
+
def self.header_string(value)
|
|
124
|
+
return nil unless value.is_a?(String)
|
|
125
|
+
|
|
126
|
+
value.dup.force_encoding(Encoding::UTF_8).scrub
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Occurrence id from the OccurrenceId header, the CorrelationId header or the correlation id property.
|
|
130
|
+
def self.parse_occurrence_id(headers, correlation_id)
|
|
131
|
+
headers ||= {}
|
|
132
|
+
|
|
133
|
+
[header_string(headers["OccurrenceId"]), header_string(headers["CorrelationId"]), correlation_id].each do |candidate|
|
|
134
|
+
value = candidate.to_s.strip
|
|
135
|
+
return value if GUID.match?(value)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.parse_retry_count(headers)
|
|
142
|
+
value = (headers || {})["x-retry-count"]
|
|
143
|
+
|
|
144
|
+
case value
|
|
145
|
+
when Integer
|
|
146
|
+
[0, value].max
|
|
147
|
+
when String
|
|
148
|
+
if value.strip.match?(/\A\d+\z/)
|
|
149
|
+
value.strip.to_i
|
|
150
|
+
elsif value.bytesize >= 4
|
|
151
|
+
# The .NET SDK reads a 4-byte value as a little-endian int.
|
|
152
|
+
[0, value.byteslice(0, 4).unpack1("l<")].max
|
|
153
|
+
else
|
|
154
|
+
0
|
|
155
|
+
end
|
|
156
|
+
else
|
|
157
|
+
0
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.serialize_result(value, job_name)
|
|
162
|
+
return "Job #{job_name} completed successfully" if value.nil?
|
|
163
|
+
return value.dup.force_encoding(Encoding::UTF_8).scrub if value.is_a?(String)
|
|
164
|
+
|
|
165
|
+
JSON.generate(value)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
value.to_s
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.seconds(value)
|
|
171
|
+
float = value.to_f
|
|
172
|
+
float == float.to_i ? float.to_i.to_s : float.to_s
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def monotonic
|
|
178
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def change_count(delta)
|
|
182
|
+
@count_mutex.synchronize do
|
|
183
|
+
@running += delta
|
|
184
|
+
@idle.broadcast if @running.zero?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
on_count_changed&.call
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def process(delivery, consumer)
|
|
191
|
+
job = Processor.parse_job(delivery.body)
|
|
192
|
+
|
|
193
|
+
if job.nil?
|
|
194
|
+
logger.error("Received a message on #{consumer.queue} that is not a valid job. Sending it to the dead letter queue.")
|
|
195
|
+
settle { delivery.nack.call(false) }
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
occurrence_id = Processor.parse_occurrence_id(delivery.headers, delivery.correlation_id)
|
|
200
|
+
|
|
201
|
+
if occurrence_id.nil?
|
|
202
|
+
logger.error("Job #{job['id']} arrived without an occurrence id. Sending it to the dead letter queue.")
|
|
203
|
+
settle { delivery.nack.call(false) }
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
run = JobRun.new(self, job, occurrence_id, Processor.parse_retry_count(delivery.headers))
|
|
208
|
+
|
|
209
|
+
if delivery.redelivered
|
|
210
|
+
logger.warn("Job #{job['id']} (occurrence #{occurrence_id}) was redelivered; the previous attempt may have crashed.")
|
|
211
|
+
run.status(Status::RUNNING, start: Time.now.utc)
|
|
212
|
+
run.log("Warning", "⚠️ Job redelivered - previous execution may have crashed or timed out. Re-executing from the beginning.",
|
|
213
|
+
{ "Reason" => "RabbitMQ redelivery detected", "WorkerId" => options.instance_id }, "Redelivery")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
abort = AbortSignal.new
|
|
217
|
+
cancellations.register(occurrence_id, abort)
|
|
218
|
+
|
|
219
|
+
begin
|
|
220
|
+
execute(delivery, consumer, run, abort)
|
|
221
|
+
ensure
|
|
222
|
+
cancellations.unregister(occurrence_id)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def execute(delivery, consumer, run, abort)
|
|
227
|
+
job = run.job
|
|
228
|
+
start = Time.now.utc
|
|
229
|
+
|
|
230
|
+
run.status(Status::RUNNING, start: start)
|
|
231
|
+
run.flush_logs
|
|
232
|
+
|
|
233
|
+
registered = jobs[job["jobNameInWorker"].downcase]
|
|
234
|
+
|
|
235
|
+
if registered.nil?
|
|
236
|
+
exception = "No IJob implementation registered for type: #{job['jobNameInWorker']}"
|
|
237
|
+
logger.error(exception)
|
|
238
|
+
run.status(Status::FAILED, end_time: Time.now.utc, exception: exception)
|
|
239
|
+
dead_letter(delivery, run, "[PERMANENT FAILURE] #{exception}", run.retry_count, 0)
|
|
240
|
+
return
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
job_timeout = job["executionTimeoutSeconds"]
|
|
244
|
+
timeout = job_timeout.nil? ? registered.execution_timeout.to_f : job_timeout.to_f
|
|
245
|
+
|
|
246
|
+
outcome = run_handler(registered, run, abort, timeout)
|
|
247
|
+
timing = { start: start, end: Time.now.utc }
|
|
248
|
+
timing[:duration_ms] = ((timing[:end] - start) * 1000).to_i
|
|
249
|
+
|
|
250
|
+
if outcome.abort
|
|
251
|
+
finish_aborted(delivery, run, outcome, timing, timeout)
|
|
252
|
+
elsif outcome.error
|
|
253
|
+
finish_failed(delivery, consumer, registered, run, outcome.error, timing)
|
|
254
|
+
else
|
|
255
|
+
run.flush_logs
|
|
256
|
+
run.status(Status::COMPLETED, start: start, end_time: timing[:end], duration_ms: timing[:duration_ms], result: outcome.result)
|
|
257
|
+
settle { delivery.ack.call }
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def run_handler(registered, run, abort, timeout)
|
|
262
|
+
mutex = Mutex.new
|
|
263
|
+
finished = ConditionVariable.new
|
|
264
|
+
box = nil
|
|
265
|
+
|
|
266
|
+
abort.on_abort { mutex.synchronize { finished.broadcast } }
|
|
267
|
+
|
|
268
|
+
context = JobContext.new(
|
|
269
|
+
occurrence_id: run.occurrence_id,
|
|
270
|
+
job: run.job,
|
|
271
|
+
retry_count: run.retry_count,
|
|
272
|
+
instance_id: options.instance_id,
|
|
273
|
+
abort: abort,
|
|
274
|
+
sink: ->(log) { logs.add(run.occurrence_id, options.instance_id, log) }
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
thread = Thread.new do
|
|
278
|
+
Thread.current.name = "milvaion-job"
|
|
279
|
+
Thread.current.report_on_exception = false
|
|
280
|
+
|
|
281
|
+
outcome =
|
|
282
|
+
begin
|
|
283
|
+
[:ok, invoke(registered.handler, context)]
|
|
284
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- every failure of user code is reported
|
|
285
|
+
[:error, e]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
mutex.synchronize do
|
|
289
|
+
box = outcome
|
|
290
|
+
finished.broadcast
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
wait_for_handler(run, abort, timeout, mutex, finished) { box }
|
|
295
|
+
|
|
296
|
+
if box && !abort.aborted?
|
|
297
|
+
kind, value = box
|
|
298
|
+
return Outcome.new(result: Processor.serialize_result(value, run.job["jobNameInWorker"])) if kind == :ok
|
|
299
|
+
return Outcome.new(abort: value.reason, abort_message: value.message) if value.is_a?(JobAborted)
|
|
300
|
+
|
|
301
|
+
return Outcome.new(error: value)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Ruby cannot stop a thread safely from outside. The occurrence settles right away; the handler
|
|
305
|
+
# thread keeps running until it notices the abort (ctx.aborted?, ctx.sleep) or returns, unless
|
|
306
|
+
# the job opted into interrupt_on_abort.
|
|
307
|
+
if registered.interrupt_on_abort && thread.alive?
|
|
308
|
+
thread.raise(JobAborted.new(abort.reason, abort.message))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
Outcome.new(abort: abort.reason || AbortReason::CANCELLED, abort_message: abort.message)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def invoke(handler, context)
|
|
315
|
+
if handler.is_a?(Class)
|
|
316
|
+
handler.new.perform(context)
|
|
317
|
+
else
|
|
318
|
+
handler.call(context)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Waits until the handler finishes or the job is aborted, firing the timeout and sending job heartbeats meanwhile.
|
|
323
|
+
def wait_for_handler(run, abort, timeout, mutex, finished, &result)
|
|
324
|
+
now = monotonic
|
|
325
|
+
deadline = timeout.positive? ? now + timeout : nil
|
|
326
|
+
interval = options.job_heartbeat_interval.to_f
|
|
327
|
+
next_heartbeat = interval.positive? ? now + interval : nil
|
|
328
|
+
|
|
329
|
+
loop do
|
|
330
|
+
mutex.synchronize do
|
|
331
|
+
while result.call.nil? && !abort.aborted?
|
|
332
|
+
wake = [deadline, next_heartbeat].compact.min
|
|
333
|
+
|
|
334
|
+
if wake.nil?
|
|
335
|
+
finished.wait(mutex)
|
|
336
|
+
else
|
|
337
|
+
remaining = wake - monotonic
|
|
338
|
+
break if remaining <= 0
|
|
339
|
+
|
|
340
|
+
finished.wait(mutex, remaining)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
return if !mutex.synchronize { result.call }.nil? || abort.aborted?
|
|
346
|
+
|
|
347
|
+
now = monotonic
|
|
348
|
+
|
|
349
|
+
if deadline && now >= deadline
|
|
350
|
+
abort.trigger(AbortReason::TIMED_OUT, "Job execution exceeded timeout limit of #{Processor.seconds(timeout)} seconds")
|
|
351
|
+
return
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
if next_heartbeat && now >= next_heartbeat
|
|
355
|
+
run.status(Status::RUNNING, reliable: false)
|
|
356
|
+
next_heartbeat = monotonic + interval
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def finish_aborted(delivery, run, outcome, timing, timeout)
|
|
362
|
+
if outcome.abort == AbortReason::SHUTDOWN
|
|
363
|
+
# Nobody cancelled this job; it goes back to the queue and runs again.
|
|
364
|
+
logger.warn("Job #{run.job['id']} (occurrence #{run.occurrence_id}) was interrupted by worker shutdown. Returning it to the queue.")
|
|
365
|
+
run.flush_logs
|
|
366
|
+
settle { delivery.nack.call(true) }
|
|
367
|
+
return
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
if outcome.abort == AbortReason::TIMED_OUT
|
|
371
|
+
run.log("Error", "Job execution exceeded timeout limit of #{Processor.seconds(timeout)} seconds", nil, "UserDefined")
|
|
372
|
+
run.flush_logs
|
|
373
|
+
run.status(Status::TIMED_OUT, start: timing[:start], end_time: timing[:end], duration_ms: timing[:duration_ms],
|
|
374
|
+
exception: "Job execution exceeded maximum timeout of #{Processor.seconds(timeout)} seconds (#{timing[:duration_ms]}ms elapsed)")
|
|
375
|
+
else
|
|
376
|
+
run.log("Warning", "Job execution was cancelled", { "Reason" => outcome.abort_message })
|
|
377
|
+
run.flush_logs
|
|
378
|
+
run.status(Status::CANCELLED, start: timing[:start], end_time: timing[:end], duration_ms: timing[:duration_ms],
|
|
379
|
+
result: "Job cancelled by user or system")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
settle { delivery.ack.call }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def finish_failed(delivery, consumer, registered, run, error, timing)
|
|
386
|
+
exception = Milvaion.format_error(error)
|
|
387
|
+
|
|
388
|
+
if error.is_a?(PermanentJobError)
|
|
389
|
+
run.log_error("Permanent job failure (will not retry): #{error.message}", error)
|
|
390
|
+
run.log("Error", "❌ Permanent failure - job will not be retried. Moving to Dead Letter Queue (DLQ).",
|
|
391
|
+
{ "FailureType" => "Permanent", "FinalStatus" => "MovedToDLQ" }, "Retry")
|
|
392
|
+
run.flush_logs
|
|
393
|
+
run.status(Status::FAILED, start: timing[:start], end_time: timing[:end], duration_ms: timing[:duration_ms], exception: exception)
|
|
394
|
+
dead_letter(delivery, run, "[PERMANENT FAILURE] #{exception}", run.retry_count, 0)
|
|
395
|
+
return
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
run.log_error("Job execution failed: #{error.message}", error)
|
|
399
|
+
|
|
400
|
+
if run.retry_count < registered.max_retries
|
|
401
|
+
delay = registered.base_retry_delay * (2**run.retry_count)
|
|
402
|
+
|
|
403
|
+
run.log("Warning", "⚠️ Job failed (attempt #{run.retry_count + 1}/#{registered.max_retries}). Retrying in #{Processor.seconds(delay)}s...", {
|
|
404
|
+
"RetryAttempt" => run.retry_count + 1,
|
|
405
|
+
"MaxRetries" => registered.max_retries,
|
|
406
|
+
"DelaySeconds" => delay,
|
|
407
|
+
"NextRetryTime" => Milvaion.iso(Time.now + delay)
|
|
408
|
+
}, "Retry")
|
|
409
|
+
run.flush_logs
|
|
410
|
+
retry_later(delivery, consumer, run, delay, registered.max_retries)
|
|
411
|
+
return
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
final = "Max retries (#{registered.max_retries}) exceeded. Original exception: #{exception}"
|
|
415
|
+
|
|
416
|
+
run.log("Error", "❌ Job failed after #{registered.max_retries} retries. Moving to Dead Letter Queue (DLQ).",
|
|
417
|
+
{ "TotalRetries" => registered.max_retries, "FinalStatus" => "MovedToDLQ" }, "Retry")
|
|
418
|
+
run.flush_logs
|
|
419
|
+
run.status(Status::FAILED, start: timing[:start], end_time: Time.now.utc, duration_ms: timing[:duration_ms], exception: final)
|
|
420
|
+
dead_letter(delivery, run, final, registered.max_retries, registered.max_retries)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Waits out the backoff, republishes the job with a higher retry count and acks the original.
|
|
424
|
+
# The original stays unacked meanwhile, so a crash during the wait gets it redelivered.
|
|
425
|
+
def retry_later(delivery, consumer, run, delay, max_retries)
|
|
426
|
+
interval = options.job_heartbeat_interval.to_f
|
|
427
|
+
remaining = delay.to_f
|
|
428
|
+
|
|
429
|
+
while remaining.positive?
|
|
430
|
+
step = interval.positive? ? [remaining, interval].min : remaining
|
|
431
|
+
|
|
432
|
+
if stopping? || retry_wait.call(step)
|
|
433
|
+
logger.warn("Worker is stopping during the retry delay of job #{run.job['id']}. Returning it to the queue.")
|
|
434
|
+
settle { delivery.nack.call(true) }
|
|
435
|
+
return
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
remaining -= step
|
|
439
|
+
run.status(Status::RUNNING, reliable: false) if remaining.positive? && interval.positive?
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
begin
|
|
443
|
+
publisher.publish("", consumer.queue, delivery.body, persistent: true, correlation_id: run.occurrence_id,
|
|
444
|
+
headers: { "CorrelationId" => run.occurrence_id, "x-retry-count" => run.retry_count + 1, "MaxRetries" => max_retries })
|
|
445
|
+
rescue StandardError => e
|
|
446
|
+
logger.error("Failed to republish job #{run.job['id']} for retry. Dead-lettering the message: #{e.message}")
|
|
447
|
+
settle { delivery.nack.call(false) }
|
|
448
|
+
return
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
settle { delivery.ack.call }
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def dead_letter(delivery, run, exception, retry_count, max_retries)
|
|
455
|
+
job = run.job
|
|
456
|
+
body = JSON.generate({
|
|
457
|
+
"id" => job["id"],
|
|
458
|
+
"displayName" => job["displayName"],
|
|
459
|
+
"jobNameInWorker" => job["jobNameInWorker"],
|
|
460
|
+
"jobData" => job["jobData"],
|
|
461
|
+
"executeAt" => job["executeAt"],
|
|
462
|
+
"status" => Status::FAILED,
|
|
463
|
+
"exception" => exception
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
begin
|
|
467
|
+
publisher.publish(Protocol::EXCHANGE_DEAD_LETTER, Protocol::DEAD_LETTER_ROUTING_KEY, body, persistent: true, correlation_id: run.occurrence_id,
|
|
468
|
+
headers: { "CorrelationId" => run.occurrence_id, "x-retry-count" => retry_count, "MaxRetries" => max_retries })
|
|
469
|
+
rescue StandardError => e
|
|
470
|
+
# The queue dead-letters a rejected message itself, just without the exception details.
|
|
471
|
+
logger.error("Failed to publish job #{job['id']} to the dead letter queue. Rejecting the message instead: #{e.message}")
|
|
472
|
+
settle { delivery.nack.call(false) }
|
|
473
|
+
return
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
settle { delivery.ack.call }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def settle
|
|
480
|
+
yield
|
|
481
|
+
rescue StandardError => e
|
|
482
|
+
# An ack or nack on a channel that has closed: the broker redelivers the message.
|
|
483
|
+
logger.warn("Could not settle a message; the broker will redeliver it: #{e.message}")
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# State and reporting helpers of one execution.
|
|
488
|
+
class JobRun
|
|
489
|
+
attr_reader :job, :occurrence_id, :retry_count
|
|
490
|
+
|
|
491
|
+
def initialize(processor, job, occurrence_id, retry_count)
|
|
492
|
+
@p = processor
|
|
493
|
+
@job = job
|
|
494
|
+
@occurrence_id = occurrence_id
|
|
495
|
+
@retry_count = retry_count
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def log(level, message, data = nil, category = "UserCode")
|
|
499
|
+
@p.logs.add(occurrence_id, @p.options.instance_id, {
|
|
500
|
+
"timestamp" => Milvaion.utc_now, "level" => level, "message" => message, "data" => data,
|
|
501
|
+
"category" => category, "exceptionType" => nil
|
|
502
|
+
})
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def log_error(message, error)
|
|
506
|
+
type = Milvaion.error_type_name(error)
|
|
507
|
+
|
|
508
|
+
@p.logs.add(occurrence_id, @p.options.instance_id, {
|
|
509
|
+
"timestamp" => Milvaion.utc_now,
|
|
510
|
+
"level" => "Error",
|
|
511
|
+
"message" => message,
|
|
512
|
+
"data" => { "ExceptionType" => type, "StackTrace" => Milvaion.format_error(error), "InnerException" => error.cause&.message },
|
|
513
|
+
"category" => "UserDefined",
|
|
514
|
+
"exceptionType" => type
|
|
515
|
+
})
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def flush_logs
|
|
519
|
+
@p.logs.flush
|
|
520
|
+
rescue StandardError => e
|
|
521
|
+
@p.logger.warn("Failed to flush job logs; they stay buffered and will be retried: #{e.message}")
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Publishes a status update. Final statuses are retried, because losing one leaves the
|
|
525
|
+
# occurrence running until the zombie detector gives up on it.
|
|
526
|
+
def status(status, start: nil, end_time: nil, duration_ms: nil, result: nil, exception: nil, reliable: true)
|
|
527
|
+
body = JSON.generate({
|
|
528
|
+
"occurrenceId" => occurrence_id,
|
|
529
|
+
"jobId" => job["id"],
|
|
530
|
+
"workerId" => @p.options.worker_id,
|
|
531
|
+
"instanceId" => @p.options.instance_id,
|
|
532
|
+
"status" => status,
|
|
533
|
+
"startTime" => Milvaion.iso(start),
|
|
534
|
+
"endTime" => Milvaion.iso(end_time),
|
|
535
|
+
"durationMs" => duration_ms,
|
|
536
|
+
"result" => result,
|
|
537
|
+
"exception" => exception,
|
|
538
|
+
"messageTimestamp" => Milvaion.utc_now
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
delays = reliable ? Processor::STATUS_RETRY_DELAYS : []
|
|
542
|
+
attempt = 0
|
|
543
|
+
|
|
544
|
+
begin
|
|
545
|
+
@p.publisher.publish("", Protocol::QUEUE_STATUS_UPDATES, body, persistent: true)
|
|
546
|
+
rescue StandardError => e
|
|
547
|
+
if attempt >= delays.size
|
|
548
|
+
@p.logger.warn("Failed to publish status #{Status.name_of(status)} for occurrence #{occurrence_id}: #{e.message}")
|
|
549
|
+
return
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
@p.status_sleep.call(delays[attempt])
|
|
553
|
+
attempt += 1
|
|
554
|
+
retry
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Milvaion
|
|
4
|
+
# Wire-level names shared with the Milvaion API.
|
|
5
|
+
# See docs/githubdocs/WORKER-PROTOCOL.md in the repository for the full protocol.
|
|
6
|
+
module Protocol
|
|
7
|
+
EXCHANGE_JOBS = "jobs.topic"
|
|
8
|
+
EXCHANGE_DEAD_LETTER = "dlx_scheduled_jobs"
|
|
9
|
+
DEAD_LETTER_ROUTING_KEY = "failed_jobs"
|
|
10
|
+
|
|
11
|
+
QUEUE_JOBS = "scheduled_jobs_queue"
|
|
12
|
+
QUEUE_WORKER_LOGS = "worker_logs_queue"
|
|
13
|
+
QUEUE_WORKER_HEARTBEAT = "worker_heartbeat_queue"
|
|
14
|
+
QUEUE_WORKER_REGISTRATION = "worker_registration_queue"
|
|
15
|
+
QUEUE_STATUS_UPDATES = "job_status_updates_queue"
|
|
16
|
+
QUEUE_FAILED_OCCURRENCES = "failed_jobs_queue"
|
|
17
|
+
|
|
18
|
+
DEFAULT_KEY_PREFIX = "Milvaion:JobScheduler:"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The API's JobOccurrenceStatus, sent as a number.
|
|
22
|
+
module Status
|
|
23
|
+
QUEUED = 0
|
|
24
|
+
RUNNING = 1
|
|
25
|
+
COMPLETED = 2
|
|
26
|
+
FAILED = 3
|
|
27
|
+
CANCELLED = 4
|
|
28
|
+
TIMED_OUT = 5
|
|
29
|
+
UNKNOWN = 6
|
|
30
|
+
SKIPPED = 7
|
|
31
|
+
|
|
32
|
+
def self.name_of(value)
|
|
33
|
+
constants.find { |name| const_get(name) == value }&.to_s || value.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Current UTC time as an ISO 8601 string with milliseconds, for example 2026-11-13T09:00:00.000Z.
|
|
38
|
+
def self.utc_now
|
|
39
|
+
iso(Time.now)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.iso(time)
|
|
43
|
+
time&.utc&.iso8601(3)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Milvaion
|
|
4
|
+
# A fixed-size pool of threads that run job deliveries. Items wait in a FIFO queue until a thread is free.
|
|
5
|
+
class ThreadPool
|
|
6
|
+
attr_reader :size
|
|
7
|
+
|
|
8
|
+
def initialize(size, logger, name: "milvaion-pool")
|
|
9
|
+
@size = size
|
|
10
|
+
@logger = logger
|
|
11
|
+
@name = name
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
@available = ConditionVariable.new
|
|
14
|
+
@items = []
|
|
15
|
+
@closed = false
|
|
16
|
+
@threads = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
next unless @threads.empty?
|
|
22
|
+
|
|
23
|
+
@threads = Array.new(size) do |index|
|
|
24
|
+
Thread.new do
|
|
25
|
+
Thread.current.name = "#{@name}-#{index}"
|
|
26
|
+
work
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Queues an item. Returns false once the pool is closed; the caller then handles the item itself.
|
|
35
|
+
def post(item, &block)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
return false if @closed
|
|
38
|
+
|
|
39
|
+
@items << [item, block]
|
|
40
|
+
@available.signal
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Stops taking items and returns the ones that have not started yet.
|
|
47
|
+
def close
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@closed = true
|
|
50
|
+
@available.broadcast
|
|
51
|
+
taken = @items.map(&:first)
|
|
52
|
+
@items.clear
|
|
53
|
+
taken
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def join(timeout)
|
|
58
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
59
|
+
|
|
60
|
+
@threads.each do |thread|
|
|
61
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
62
|
+
thread.join([remaining, 0].max)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def work
|
|
69
|
+
loop do
|
|
70
|
+
item, block = @mutex.synchronize do
|
|
71
|
+
@available.wait(@mutex) while @items.empty? && !@closed
|
|
72
|
+
return if @items.empty?
|
|
73
|
+
|
|
74
|
+
@items.shift
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
block.call(item)
|
|
79
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- a pool thread must survive any item
|
|
80
|
+
@logger.error("Unexpected error in the job pool: #{e.class}: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|