milvaion-worker 1.0.0 → 1.1.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.
@@ -20,7 +20,9 @@ module Milvaion
20
20
  startup_timeout: 30,
21
21
  version: "1.0.0",
22
22
  instance_id: nil,
23
- logger: nil
23
+ logger: nil,
24
+ transport: "rabbitmq",
25
+ kafka: nil
24
26
  }.freeze
25
27
 
26
28
  # worker_id: identifies the worker application. Use the same value on every replica.
@@ -37,7 +39,9 @@ module Milvaion
37
39
  # version: shown in the dashboard.
38
40
  # instance_id: overrides the generated "{worker_id}-{8 hex}" id. Must be unique per process.
39
41
  # logger: a Logger. Defaults to one that writes to $stdout at INFO.
40
- attr_reader :worker_id, :rabbitmq_url, :queue_type, :redis_url, :redis_key_prefix, :max_parallel_jobs,
42
+ # transport: "rabbitmq" (default) or "kafka". The API and every worker of a deployment must use the same one.
43
+ # kafka: Kafka settings (a Hash, see Milvaion::Kafka::Settings::KEYS). Only used when transport is "kafka".
44
+ attr_reader :transport, :kafka, :worker_id, :rabbitmq_url, :queue_type, :redis_url, :redis_key_prefix, :max_parallel_jobs,
41
45
  :execution_timeout, :heartbeat_interval, :job_heartbeat_interval, :shutdown_timeout,
42
46
  :startup_timeout, :version, :instance_id, :logger
43
47
 
@@ -67,6 +71,17 @@ module Milvaion
67
71
  @version = values[:version]
68
72
  @instance_id = values[:instance_id].to_s.empty? ? "#{@worker_id}-#{SecureRandom.hex(4)}" : values[:instance_id]
69
73
  @logger = values[:logger] || Logger.new($stdout, progname: "milvaion", level: Logger::INFO)
74
+
75
+ @transport = (values[:transport] || "rabbitmq").to_s.strip.downcase
76
+ @transport = "rabbitmq" if @transport.empty?
77
+ raise ArgumentError, %(transport must be "rabbitmq" or "kafka", got #{values[:transport].inspect}) unless %w[rabbitmq kafka].include?(@transport)
78
+
79
+ # Validated here, so a bad setting fails before anything connects.
80
+ @kafka = kafka? ? Kafka::Settings.new(values[:kafka], @max_parallel_jobs) : nil
81
+ end
82
+
83
+ def kafka?
84
+ transport == "kafka"
70
85
  end
71
86
 
72
87
  def cancellation_channel
@@ -77,6 +92,7 @@ module Milvaion
77
92
  "MILVAION_WORKER_ID" => [:worker_id, :string],
78
93
  "MILVAION_RABBITMQ_URL" => [:rabbitmq_url, :string],
79
94
  "MILVAION_RABBITMQ_QUEUE_TYPE" => [:queue_type, :string],
95
+ "MILVAION_TRANSPORT" => [:transport, :string],
80
96
  "MILVAION_REDIS_KEY_PREFIX" => [:redis_key_prefix, :string],
81
97
  "MILVAION_MAX_PARALLEL_JOBS" => [:max_parallel_jobs, :integer],
82
98
  "MILVAION_EXECUTION_TIMEOUT_SECONDS" => [:execution_timeout, :float],
@@ -85,8 +101,9 @@ module Milvaion
85
101
 
86
102
  # Reads MILVAION_WORKER_ID, MILVAION_RABBITMQ_URL, MILVAION_RABBITMQ_QUEUE_TYPE, MILVAION_REDIS_URL
87
103
  # ("false" disables Redis), MILVAION_REDIS_KEY_PREFIX, MILVAION_MAX_PARALLEL_JOBS,
88
- # MILVAION_EXECUTION_TIMEOUT_SECONDS and MILVAION_HEARTBEAT_INTERVAL_SECONDS.
89
- # Keyword arguments win over the environment.
104
+ # MILVAION_EXECUTION_TIMEOUT_SECONDS and MILVAION_HEARTBEAT_INTERVAL_SECONDS, and the transport
105
+ # from MILVAION_TRANSPORT and the MILVAION_KAFKA_* variables (see Milvaion::Kafka.options_from_env).
106
+ # Keyword arguments win over the environment; a kafka: Hash wins key by key.
90
107
  def self.from_env(env = ENV, **overrides)
91
108
  values = {}
92
109
 
@@ -112,7 +129,11 @@ module Milvaion
112
129
  values[:redis_url] = redis_url
113
130
  end
114
131
 
115
- values.merge!(overrides)
132
+ kafka = Kafka.options_from_env(env)
133
+ kafka = kafka.merge(overrides[:kafka].to_h.transform_keys(&:to_sym)) if overrides[:kafka]
134
+ values[:kafka] = kafka unless kafka.empty?
135
+
136
+ values.merge!(overrides.except(:kafka))
116
137
  values[:worker_id] ||= ""
117
138
 
118
139
  new(**values)
@@ -3,8 +3,18 @@
3
3
  require "json"
4
4
 
5
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)
6
+ # One message taken from a job queue or topic. ack is called with no arguments, nack with the
7
+ # requeue flag: nack(true) returns the message so it runs again, nack(false) dead-letters it.
8
+ #
9
+ # reject: optional; dead-letters a message that cannot be processed, with the reason.
10
+ # Without it nack(false) is used.
11
+ # schedule_retry: optional; hands a failed job to the broker for a delayed retry instead of
12
+ # waiting in process (Kafka's retry topic). Called with a RetryRequest; returns once
13
+ # the broker has accepted the retry and raises otherwise.
14
+ Delivery = Struct.new(:body, :headers, :correlation_id, :redelivered, :ack, :nack, :reject, :schedule_retry, keyword_init: true)
15
+
16
+ # The next attempt of a failed job. retry_count is that of the next attempt; not_before a Time.
17
+ RetryRequest = Struct.new(:job_id, :occurrence_id, :retry_count, :max_retries, :not_before, keyword_init: true)
8
18
 
9
19
  # Abort signals of the jobs running on this instance, keyed by occurrence id.
10
20
  class CancellationRegistry
@@ -103,7 +113,7 @@ module Milvaion
103
113
  process(delivery, consumer)
104
114
  rescue StandardError => e
105
115
  logger.error("Unexpected error while processing a job delivery: #{e.class}: #{e.message}")
106
- settle { delivery.nack.call(false) }
116
+ reject(delivery, "unexpected error while processing the job: #{e.class}: #{e.message}")
107
117
  ensure
108
118
  change_count(-1)
109
119
  end
@@ -192,7 +202,7 @@ module Milvaion
192
202
 
193
203
  if job.nil?
194
204
  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) }
205
+ reject(delivery, "the message is not a valid job")
196
206
  return
197
207
  end
198
208
 
@@ -200,7 +210,7 @@ module Milvaion
200
210
 
201
211
  if occurrence_id.nil?
202
212
  logger.error("Job #{job['id']} arrived without an occurrence id. Sending it to the dead letter queue.")
203
- settle { delivery.nack.call(false) }
213
+ reject(delivery, "the message has no occurrence id")
204
214
  return
205
215
  end
206
216
 
@@ -421,8 +431,11 @@ module Milvaion
421
431
  end
422
432
 
423
433
  # 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.
434
+ # The original stays unacked meanwhile, so a crash during the wait gets it redelivered. A broker
435
+ # that schedules retries itself (Kafka) gets the retry right away instead.
425
436
  def retry_later(delivery, consumer, run, delay, max_retries)
437
+ return schedule_retry(delivery, run, delay, max_retries) if delivery.schedule_retry
438
+
426
439
  interval = options.job_heartbeat_interval.to_f
427
440
  remaining = delay.to_f
428
441
 
@@ -444,13 +457,67 @@ module Milvaion
444
457
  headers: { "CorrelationId" => run.occurrence_id, "x-retry-count" => run.retry_count + 1, "MaxRetries" => max_retries })
445
458
  rescue StandardError => e
446
459
  logger.error("Failed to republish job #{run.job['id']} for retry. Dead-lettering the message: #{e.message}")
447
- settle { delivery.nack.call(false) }
460
+ reject(delivery, "the job could not be republished for retry: #{e.message}")
448
461
  return
449
462
  end
450
463
 
451
464
  settle { delivery.ack.call }
452
465
  end
453
466
 
467
+ # Hands the next attempt to the broker, settles the original and keeps the occurrence's job
468
+ # heartbeat going until the attempt is due, since the occurrence stays Running.
469
+ def schedule_retry(delivery, run, delay, max_retries)
470
+ due = Time.now + delay
471
+
472
+ begin
473
+ delivery.schedule_retry.call(RetryRequest.new(job_id: run.job["id"], occurrence_id: run.occurrence_id, retry_count: run.retry_count + 1,
474
+ max_retries: max_retries, not_before: due))
475
+ rescue StandardError => e
476
+ logger.error("Failed to schedule job #{run.job['id']} for retry. Dead-lettering the message: #{e.message}")
477
+ reject(delivery, "the job could not be scheduled for retry: #{e.message}")
478
+ return
479
+ end
480
+
481
+ settle { delivery.ack.call }
482
+
483
+ Thread.new do
484
+ Thread.current.name = "milvaion-retry-heartbeat"
485
+ heartbeat_while_waiting(run, due)
486
+ end
487
+ end
488
+
489
+ public
490
+
491
+ # Sends Running statuses every job_heartbeat_interval for an occurrence that waits for its next
492
+ # attempt, until due, until stop (an AbortSignal) is triggered or the worker stops.
493
+ def heartbeat_while_waiting(run, due, stop = nil)
494
+ interval = options.job_heartbeat_interval.to_f
495
+ return unless interval.positive?
496
+
497
+ loop do
498
+ remaining = due - Time.now
499
+ return if remaining <= 0 || stopping?
500
+
501
+ step = [remaining, interval].min
502
+ return if stop ? stop.wait(step) : @shutdown.wait(step)
503
+
504
+ run.status(Status::RUNNING, reliable: false) if due > Time.now && !stopping?
505
+ end
506
+ rescue StandardError => e
507
+ logger.debug("Job heartbeat while waiting for a retry failed: #{e.message}")
508
+ end
509
+
510
+ private
511
+
512
+ # Dead-letters a message that cannot be processed.
513
+ def reject(delivery, reason)
514
+ if delivery.reject
515
+ settle { delivery.reject.call(reason) }
516
+ else
517
+ settle { delivery.nack.call(false) }
518
+ end
519
+ end
520
+
454
521
  def dead_letter(delivery, run, exception, retry_count, max_retries)
455
522
  job = run.job
456
523
  body = JSON.generate({
@@ -465,11 +532,11 @@ module Milvaion
465
532
 
466
533
  begin
467
534
  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 })
535
+ key: run.occurrence_id, headers: { "CorrelationId" => run.occurrence_id, "x-retry-count" => retry_count, "MaxRetries" => max_retries })
469
536
  rescue StandardError => e
470
537
  # The queue dead-letters a rejected message itself, just without the exception details.
471
538
  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) }
539
+ reject(delivery, "the dead letter message could not be published: #{e.message}")
473
540
  return
474
541
  end
475
542
 
@@ -542,7 +609,7 @@ module Milvaion
542
609
  attempt = 0
543
610
 
544
611
  begin
545
- @p.publisher.publish("", Protocol::QUEUE_STATUS_UPDATES, body, persistent: true)
612
+ @p.publisher.publish("", Protocol::QUEUE_STATUS_UPDATES, body, persistent: true, key: occurrence_id)
546
613
  rescue StandardError => e
547
614
  if attempt >= delays.size
548
615
  @p.logger.warn("Failed to publish status #{Status.name_of(status)} for occurrence #{occurrence_id}: #{e.message}")
@@ -3,6 +3,6 @@
3
3
  module Milvaion
4
4
  class Worker
5
5
  # Version of this SDK, reported to the dashboard as SdkVersion. A spec keeps it equal to the gemspec.
6
- VERSION = "1.0.0"
6
+ VERSION = "1.1.0"
7
7
  end
8
8
  end
@@ -8,6 +8,7 @@ require "time"
8
8
  require_relative "worker/version"
9
9
  require_relative "protocol"
10
10
  require_relative "errors"
11
+ require_relative "kafka"
11
12
  require_relative "options"
12
13
  require_relative "context"
13
14
  require_relative "log_buffer"
@@ -16,6 +17,7 @@ require_relative "thread_pool"
16
17
  require_relative "metrics"
17
18
  require_relative "job"
18
19
  require_relative "cancellation_listener"
20
+ require_relative "kafka_transport"
19
21
 
20
22
  module Milvaion
21
23
  # A Milvaion worker. Register jobs with #job, then call #run (blocking) or #start / #stop.
@@ -130,9 +132,12 @@ module Milvaion
130
132
  end
131
133
  end
132
134
 
133
- # Connects, registers this instance and starts consuming. Raises if RabbitMQ or Redis cannot be
135
+ # Connects, registers this instance and starts consuming. Raises if the broker or Redis cannot be
134
136
  # reached within startup_timeout.
135
137
  def start
138
+ # Before anything else, so a missing rdkafka gem fails with a clear message.
139
+ KafkaTransport.load_rdkafka! if options.kafka?
140
+
136
141
  @state_mutex.synchronize do
137
142
  raise Error, "Worker cannot be started from state #{@state}." unless @state == :idle
138
143
  raise Error, "Add at least one job before starting the worker." if @jobs.empty?
@@ -142,6 +147,11 @@ module Milvaion
142
147
 
143
148
  logger.info("Starting worker #{worker_id} (instance #{instance_id}) with #{@jobs.size} job type(s).")
144
149
 
150
+ if options.kafka?
151
+ @kafka = KafkaTransport.new(options, -> { register })
152
+ @publisher = @kafka
153
+ end
154
+
145
155
  @logs = LogBuffer.new(->(batch) { send_logs(batch) }, logger).start
146
156
  @processor = Processor.new(options, @jobs, @publisher, @logs, @cancellations)
147
157
  @processor.on_count_changed = -> { request_immediate_heartbeat }
@@ -155,7 +165,11 @@ module Milvaion
155
165
  logger.warn("Redis is disabled: jobs cannot be cancelled from the dashboard.")
156
166
  end
157
167
 
158
- connect
168
+ if @kafka
169
+ @kafka.start(@processor, @pool)
170
+ else
171
+ connect
172
+ end
159
173
  rescue Exception # rubocop:disable Lint/RescueException -- clean up on any failure, then re-raise
160
174
  set_state(:stopped)
161
175
  close
@@ -338,7 +352,7 @@ module Milvaion
338
352
  end
339
353
 
340
354
  def register
341
- @publisher.publish("", Protocol::QUEUE_WORKER_REGISTRATION, JSON.generate(build_registration), persistent: true)
355
+ @publisher.publish("", Protocol::QUEUE_WORKER_REGISTRATION, JSON.generate(build_registration), persistent: true, key: instance_id)
342
356
  send_heartbeat(false)
343
357
  logger.info("Registered #{worker_id} (instance #{instance_id}).")
344
358
  end
@@ -395,13 +409,14 @@ module Milvaion
395
409
  "isStopping" => stopping
396
410
  })
397
411
 
398
- @publisher.publish("", Protocol::QUEUE_WORKER_HEARTBEAT, body, persistent: false)
412
+ @publisher.publish("", Protocol::QUEUE_WORKER_HEARTBEAT, body, persistent: false, key: instance_id)
399
413
  rescue StandardError => e
400
414
  logger.debug("Heartbeat failed; will retry on the next interval: #{e.message}")
401
415
  end
402
416
 
403
417
  def send_logs(batch)
404
- @publisher.publish("", Protocol::QUEUE_WORKER_LOGS, JSON.generate(batch), persistent: true)
418
+ first = batch.is_a?(Hash) ? Array(batch["logs"]).first : nil
419
+ @publisher.publish("", Protocol::QUEUE_WORKER_LOGS, JSON.generate(batch), persistent: true, key: first.is_a?(Hash) ? first["occurrenceId"] : nil)
405
420
  end
406
421
 
407
422
  def shutdown
@@ -414,6 +429,7 @@ module Milvaion
414
429
  nil
415
430
  end
416
431
  @consumers.clear
432
+ @kafka&.stop_consuming
417
433
 
418
434
  # Deliveries waiting for a pool thread have not started: give them straight back.
419
435
  @pool.close.each do |delivery|
@@ -430,6 +446,9 @@ module Milvaion
430
446
  @processor.wait_for_idle(10)
431
447
  end
432
448
 
449
+ # Kafka: commit what has finished and leave the group.
450
+ @kafka&.drained
451
+
433
452
  @heartbeat_mutex.synchronize do
434
453
  @heartbeat_stopped = true
435
454
  @heartbeat_changed.broadcast
@@ -453,6 +472,7 @@ module Milvaion
453
472
  @listener&.stop
454
473
  @logs&.close
455
474
  @publisher.detach
475
+ @kafka&.close
456
476
  @pool&.close
457
477
  @pool&.join(5)
458
478
 
@@ -486,7 +506,8 @@ module Milvaion
486
506
  end
487
507
  end
488
508
 
489
- def publish(exchange, routing_key, body, persistent: true, correlation_id: nil, headers: nil)
509
+ # key is the Kafka record key; RabbitMQ has no use for it.
510
+ def publish(exchange, routing_key, body, persistent: true, correlation_id: nil, headers: nil, key: nil) # rubocop:disable Lint/UnusedMethodArgument
490
511
  @mutex.synchronize do
491
512
  channel = @channel
492
513
  raise ConnectionError, "Not connected to RabbitMQ." if channel.nil? || !channel.open?
@@ -3,12 +3,12 @@
3
3
  Gem::Specification.new do |spec|
4
4
  spec.name = "milvaion-worker"
5
5
  # Kept equal to Milvaion::Worker::VERSION by a spec; the publish workflow checks it against the tag.
6
- spec.version = "1.0.0"
6
+ spec.version = "1.1.0"
7
7
  spec.authors = ["Milvasoft"]
8
8
  spec.summary = "Ruby worker SDK for the Milvaion distributed job scheduler."
9
9
  spec.description = "Run Milvaion jobs in Ruby: registration, heartbeats, live logs, retries with " \
10
10
  "exponential backoff, dead lettering, timeouts, cancellation and graceful shutdown " \
11
- "over RabbitMQ and Redis."
11
+ "over RabbitMQ (or Apache Kafka with the rdkafka gem) and Redis."
12
12
  spec.homepage = "https://github.com/Milvasoft/milvaion"
13
13
  spec.license = "Apache-2.0"
14
14
  spec.required_ruby_version = ">= 3.1"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: milvaion-worker
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Milvasoft
@@ -66,7 +66,7 @@ dependencies:
66
66
  version: '6'
67
67
  description: 'Run Milvaion jobs in Ruby: registration, heartbeats, live logs, retries
68
68
  with exponential backoff, dead lettering, timeouts, cancellation and graceful shutdown
69
- over RabbitMQ and Redis.'
69
+ over RabbitMQ (or Apache Kafka with the rdkafka gem) and Redis.'
70
70
  email:
71
71
  executables: []
72
72
  extensions: []
@@ -79,6 +79,8 @@ files:
79
79
  - lib/milvaion/context.rb
80
80
  - lib/milvaion/errors.rb
81
81
  - lib/milvaion/job.rb
82
+ - lib/milvaion/kafka.rb
83
+ - lib/milvaion/kafka_transport.rb
82
84
  - lib/milvaion/log_buffer.rb
83
85
  - lib/milvaion/metrics.rb
84
86
  - lib/milvaion/options.rb