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.
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Milvaion
4
+ class Worker
5
+ # Version of this SDK, reported to the dashboard as SdkVersion. A spec keeps it equal to the gemspec.
6
+ VERSION = "1.0.0"
7
+ end
8
+ end