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.
- checksums.yaml +4 -4
- data/README.md +68 -3
- data/lib/milvaion/kafka.rb +299 -0
- data/lib/milvaion/kafka_transport.rb +649 -0
- data/lib/milvaion/options.rb +26 -5
- data/lib/milvaion/processor.rb +77 -10
- data/lib/milvaion/worker/version.rb +1 -1
- data/lib/milvaion/worker.rb +27 -6
- data/milvaion-worker.gemspec +2 -2
- metadata +4 -2
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Milvaion
|
|
6
|
+
# Runs a worker on Kafka (docs/githubdocs/KAFKA-TRANSPORT.md). One consumer group member reads the
|
|
7
|
+
# worker's job and retry topics; records are buffered per partition, dispatched up to
|
|
8
|
+
# max_parallel_jobs (and max_in_flight_per_partition per partition) and committed in order once
|
|
9
|
+
# their outcome has been reported. It is also the publisher the processor reports through.
|
|
10
|
+
#
|
|
11
|
+
# Uses the rdkafka gem, which is not a dependency of milvaion-worker: it is loaded only when the
|
|
12
|
+
# transport is "kafka".
|
|
13
|
+
class KafkaTransport
|
|
14
|
+
COMMIT_INTERVAL = 0.5
|
|
15
|
+
POLL_TIMEOUT_MS = 100
|
|
16
|
+
PRODUCE_TIMEOUT = 30
|
|
17
|
+
RELIABLE_PRODUCE_DELAYS = [0, 1, 2, 5].freeze
|
|
18
|
+
|
|
19
|
+
# Raised when a record is produced while the transport is not connected.
|
|
20
|
+
class NotConnectedError < ConnectionError; end
|
|
21
|
+
|
|
22
|
+
# What this instance holds of one assigned partition.
|
|
23
|
+
PartitionState = Struct.new(:topic, :partition, :retry, :tracker, :backlog, :in_flight, :want_paused, :paused, :held, :committed,
|
|
24
|
+
keyword_init: true) do
|
|
25
|
+
def key
|
|
26
|
+
[topic, partition]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A record taken from the consumer, with its headers decoded.
|
|
31
|
+
Record = Struct.new(:message, :headers)
|
|
32
|
+
# The head of a retry partition that is not due yet.
|
|
33
|
+
Held = Struct.new(:offset, :stop)
|
|
34
|
+
# What the processor knows of the "queue" a record came from.
|
|
35
|
+
Source = Struct.new(:queue)
|
|
36
|
+
|
|
37
|
+
# Calls back into the transport from rdkafka's rebalance callback (on the polling thread).
|
|
38
|
+
class RebalanceListener
|
|
39
|
+
def initialize(transport)
|
|
40
|
+
@transport = transport
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Older rdkafka versions pass the consumer first.
|
|
44
|
+
def on_partitions_assigned(*args)
|
|
45
|
+
@transport.on_assigned(args.last)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def on_partitions_revoked(*args)
|
|
49
|
+
@transport.on_revoked(args.last)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.load_rdkafka!
|
|
54
|
+
return if defined?(::Rdkafka::Config)
|
|
55
|
+
|
|
56
|
+
require "rdkafka"
|
|
57
|
+
rescue LoadError => e
|
|
58
|
+
raise Error, "The Kafka transport needs the rdkafka gem. Add `gem \"rdkafka\"` to your Gemfile (#{e.message})."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Waits for an rdkafka handle (delivery, admin operation). rdkafka renamed the timeout from
|
|
62
|
+
# seconds to milliseconds; both are supported.
|
|
63
|
+
def self.wait_for(handle, seconds)
|
|
64
|
+
if handle.method(:wait).parameters.any? { |_, name| name == :max_wait_timeout_ms }
|
|
65
|
+
handle.wait(max_wait_timeout_ms: (seconds * 1000).to_i)
|
|
66
|
+
else
|
|
67
|
+
handle.wait(max_wait_timeout: seconds)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_reader :topics
|
|
72
|
+
|
|
73
|
+
# register publishes this instance's registration (and a first heartbeat); it raises on failure.
|
|
74
|
+
def initialize(options, register)
|
|
75
|
+
self.class.load_rdkafka!
|
|
76
|
+
|
|
77
|
+
@options = options
|
|
78
|
+
@settings = options.kafka
|
|
79
|
+
@register = register
|
|
80
|
+
@logger = options.logger
|
|
81
|
+
@topics = Kafka.topics(@settings.topic_prefix, options.worker_id)
|
|
82
|
+
|
|
83
|
+
@mutex = Mutex.new
|
|
84
|
+
@assigned = {}
|
|
85
|
+
@parts = {}
|
|
86
|
+
@running = 0
|
|
87
|
+
@cursor = 0
|
|
88
|
+
@stopping = false
|
|
89
|
+
@poll_stop = false
|
|
90
|
+
@heartbeat_failed = false
|
|
91
|
+
@heartbeat_mutex = Mutex.new
|
|
92
|
+
@producer = nil
|
|
93
|
+
@consumer = nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Connects, creates the topics, registers and starts consuming. Gives up after startup_timeout.
|
|
97
|
+
def start(processor, pool)
|
|
98
|
+
@processor = processor
|
|
99
|
+
@pool = pool
|
|
100
|
+
deadline = monotonic + @options.startup_timeout.to_f
|
|
101
|
+
|
|
102
|
+
# Idempotent, so the client's own retries do not write a record twice.
|
|
103
|
+
@producer = Rdkafka::Config.new(client_config.merge("message.timeout.ms": PRODUCE_TIMEOUT * 1000, "enable.idempotence": true)).producer
|
|
104
|
+
|
|
105
|
+
create_topics(deadline) if @settings.auto_create_topics
|
|
106
|
+
with_startup_retry(deadline, "register with the API") { @register.call }
|
|
107
|
+
|
|
108
|
+
config = Rdkafka::Config.new(client_config.merge(
|
|
109
|
+
"group.id": topics.group,
|
|
110
|
+
"enable.auto.commit": false,
|
|
111
|
+
"enable.auto.offset.store": false,
|
|
112
|
+
"auto.offset.reset": "earliest",
|
|
113
|
+
"partition.assignment.strategy": "cooperative-sticky"
|
|
114
|
+
))
|
|
115
|
+
config.consumer_rebalance_listener = RebalanceListener.new(self)
|
|
116
|
+
@consumer = config.consumer
|
|
117
|
+
@consumer.subscribe(topics.jobs, topics.retry)
|
|
118
|
+
|
|
119
|
+
@poll_thread = Thread.new do
|
|
120
|
+
Thread.current.name = "milvaion-kafka-poll"
|
|
121
|
+
poll_loop
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@logger.info("Consuming #{topics.jobs} and #{topics.retry} in group #{topics.group} (max parallel jobs #{@options.max_parallel_jobs}, " \
|
|
125
|
+
"max in flight per partition #{@settings.max_in_flight_per_partition}).")
|
|
126
|
+
self
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Maps the processor's RabbitMQ-named destinations to the Kafka topics and waits for the broker's
|
|
130
|
+
# acknowledgement.
|
|
131
|
+
def publish(exchange, routing_key, body, persistent: true, correlation_id: nil, headers: nil, key: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
132
|
+
topic = topic_for(exchange, routing_key)
|
|
133
|
+
record_headers = (headers || {}).dup
|
|
134
|
+
|
|
135
|
+
if correlation_id
|
|
136
|
+
record_headers[Kafka::HEADER_OCCURRENCE_ID] = correlation_id
|
|
137
|
+
record_headers[Kafka::HEADER_CORRELATION_ID] = correlation_id
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
record_key = key || correlation_id
|
|
141
|
+
heartbeat = exchange.empty? && routing_key == Protocol::QUEUE_WORKER_HEARTBEAT
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
produce(topic: topic, payload: body, key: record_key, headers: Kafka.encode_headers(record_headers))
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
after_heartbeat(e) if heartbeat
|
|
147
|
+
raise
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
after_heartbeat(nil) if heartbeat
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The processor publishes through the transport; there is nothing to detach.
|
|
154
|
+
def detach; end
|
|
155
|
+
|
|
156
|
+
# Stops taking records. Running jobs carry on and can still report their outcome; finished
|
|
157
|
+
# records keep being committed.
|
|
158
|
+
def stop_consuming
|
|
159
|
+
@mutex.synchronize do
|
|
160
|
+
@stopping = true
|
|
161
|
+
@parts.each_value { |ps| release_hold(ps) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Runs once running jobs have finished or have been written back: commits and leaves the group.
|
|
166
|
+
def drained
|
|
167
|
+
stop_polling
|
|
168
|
+
consumer = @consumer
|
|
169
|
+
return if consumer.nil?
|
|
170
|
+
|
|
171
|
+
commit
|
|
172
|
+
close_consumer
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def close
|
|
176
|
+
stop_polling
|
|
177
|
+
close_consumer
|
|
178
|
+
|
|
179
|
+
producer = @producer
|
|
180
|
+
@producer = nil
|
|
181
|
+
return if producer.nil?
|
|
182
|
+
|
|
183
|
+
begin
|
|
184
|
+
producer.flush(5_000)
|
|
185
|
+
rescue StandardError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
begin
|
|
190
|
+
producer.close
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
@logger.debug("Closing the Kafka producer failed: #{e.message}")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Rebalance callbacks, called on the polling thread.
|
|
197
|
+
|
|
198
|
+
def on_assigned(list)
|
|
199
|
+
pairs = partition_pairs(list)
|
|
200
|
+
stopping = @mutex.synchronize do
|
|
201
|
+
pairs.each { |pair| @assigned[pair] = true }
|
|
202
|
+
@stopping
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return if pairs.empty?
|
|
206
|
+
|
|
207
|
+
# A partition that was paused when this instance had it before must flow again.
|
|
208
|
+
stopping ? @consumer.pause(partition_list(pairs)) : @consumer.resume(partition_list(pairs))
|
|
209
|
+
@logger.info("Kafka partitions assigned: #{format_pairs(pairs)}")
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
@logger.warn("Handling assigned Kafka partitions failed: #{e.message}")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Commits what has finished on the revoked partitions and forgets them. Jobs still running on
|
|
215
|
+
# them finish and report, but their offsets are left to the new owner.
|
|
216
|
+
def on_revoked(list)
|
|
217
|
+
pairs = partition_pairs(list)
|
|
218
|
+
commit(pairs)
|
|
219
|
+
|
|
220
|
+
@mutex.synchronize do
|
|
221
|
+
pairs.each do |pair|
|
|
222
|
+
@assigned.delete(pair)
|
|
223
|
+
ps = @parts.delete(pair)
|
|
224
|
+
release_hold(ps) if ps
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
@logger.info("Kafka partitions revoked: #{format_pairs(pairs)}") unless pairs.empty?
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
@logger.warn("Handling revoked Kafka partitions failed: #{e.message}")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Starts buffered records while slots are free. Safe to call from any thread.
|
|
234
|
+
def dispatch
|
|
235
|
+
@mutex.synchronize { dispatch_locked }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def client_config
|
|
241
|
+
@settings.client_config(@options.instance_id)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def monotonic
|
|
245
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def topic_for(exchange, routing_key)
|
|
249
|
+
return topics.dlq if exchange == Protocol::EXCHANGE_DEAD_LETTER
|
|
250
|
+
|
|
251
|
+
if exchange.empty?
|
|
252
|
+
case routing_key
|
|
253
|
+
when Protocol::QUEUE_STATUS_UPDATES then return topics.status
|
|
254
|
+
when Protocol::QUEUE_WORKER_LOGS then return topics.logs
|
|
255
|
+
when Protocol::QUEUE_WORKER_HEARTBEAT then return topics.heartbeats
|
|
256
|
+
when Protocol::QUEUE_WORKER_REGISTRATION then return topics.registrations
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
raise Error, "No Kafka topic for exchange #{exchange.inspect} and routing key #{routing_key.inspect}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def with_startup_retry(deadline, what)
|
|
264
|
+
yield
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
raise ConnectionError, "Could not #{what} on Kafka (#{@settings.brokers.join(',')}) within #{@options.startup_timeout}s: #{e.message}" if monotonic + 1 > deadline
|
|
267
|
+
|
|
268
|
+
@logger.warn("Could not #{what} on Kafka: #{e.message}")
|
|
269
|
+
Kernel.sleep(1)
|
|
270
|
+
retry
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Creates the worker's job and retry topics and the shared topics it writes to. Topics that
|
|
274
|
+
# exist already are left as they are.
|
|
275
|
+
def create_topics(deadline)
|
|
276
|
+
admin = Rdkafka::Config.new(client_config).admin
|
|
277
|
+
|
|
278
|
+
begin
|
|
279
|
+
with_startup_retry(deadline, "create the topics") do
|
|
280
|
+
names = [topics.jobs, topics.retry, topics.status, topics.logs, topics.heartbeats, topics.registrations, topics.dlq]
|
|
281
|
+
handles = names.map { |name| [name, admin.create_topic(name, @settings.num_partitions, @settings.replication_factor)] }
|
|
282
|
+
|
|
283
|
+
handles.each do |name, handle|
|
|
284
|
+
wait_for(handle, [[deadline - monotonic, PRODUCE_TIMEOUT].min, 1].max)
|
|
285
|
+
rescue Rdkafka::RdkafkaError => e
|
|
286
|
+
raise unless e.code == :topic_already_exists
|
|
287
|
+
|
|
288
|
+
@logger.debug("Kafka topic #{name} exists.")
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
ensure
|
|
292
|
+
admin.close
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def wait_for(handle, seconds)
|
|
297
|
+
self.class.wait_for(handle, seconds)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def produce(topic:, payload:, key:, headers:, timeout: PRODUCE_TIMEOUT)
|
|
301
|
+
producer = @producer
|
|
302
|
+
raise NotConnectedError, "Not connected to Kafka." if producer.nil?
|
|
303
|
+
|
|
304
|
+
handle = producer.produce(topic: topic, payload: payload, key: key, headers: headers.nil? || headers.empty? ? nil : headers)
|
|
305
|
+
wait_for(handle, timeout)
|
|
306
|
+
rescue Rdkafka::ClosedProducerError
|
|
307
|
+
raise NotConnectedError, "Not connected to Kafka."
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Retries a produce whose failure would otherwise leave the record's offset stuck.
|
|
311
|
+
def produce_reliably(**record)
|
|
312
|
+
last = nil
|
|
313
|
+
|
|
314
|
+
RELIABLE_PRODUCE_DELAYS.each do |delay|
|
|
315
|
+
Kernel.sleep(delay) if delay.positive?
|
|
316
|
+
|
|
317
|
+
begin
|
|
318
|
+
return produce(**record)
|
|
319
|
+
rescue NotConnectedError
|
|
320
|
+
raise
|
|
321
|
+
rescue StandardError => e
|
|
322
|
+
last = e
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
raise last
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Registers again once heartbeats get through after failing, the Kafka counterpart of
|
|
330
|
+
# registering after a RabbitMQ reconnect: the API may have dropped the instance meanwhile.
|
|
331
|
+
def after_heartbeat(error)
|
|
332
|
+
if error
|
|
333
|
+
@heartbeat_mutex.synchronize { @heartbeat_failed = true } unless error.is_a?(NotConnectedError)
|
|
334
|
+
return
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
failed = @heartbeat_mutex.synchronize do
|
|
338
|
+
was = @heartbeat_failed
|
|
339
|
+
@heartbeat_failed = false
|
|
340
|
+
was
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
return unless failed
|
|
344
|
+
|
|
345
|
+
Thread.new do
|
|
346
|
+
@register.call
|
|
347
|
+
rescue StandardError => e
|
|
348
|
+
@logger.warn("Could not register again after Kafka recovered: #{e.message}")
|
|
349
|
+
@heartbeat_mutex.synchronize { @heartbeat_failed = true }
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def poll_loop
|
|
354
|
+
last_commit = monotonic
|
|
355
|
+
paused_for_stop = false
|
|
356
|
+
|
|
357
|
+
until @poll_stop
|
|
358
|
+
begin
|
|
359
|
+
message = @consumer.poll(POLL_TIMEOUT_MS)
|
|
360
|
+
accept(message) if message
|
|
361
|
+
rescue Rdkafka::RdkafkaError => e
|
|
362
|
+
@logger.warn("Kafka poll failed: #{e.message}")
|
|
363
|
+
Kernel.sleep(1) unless @poll_stop
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
begin
|
|
367
|
+
if @mutex.synchronize { @stopping }
|
|
368
|
+
unless paused_for_stop
|
|
369
|
+
paused_for_stop = true
|
|
370
|
+
pause_everything
|
|
371
|
+
end
|
|
372
|
+
else
|
|
373
|
+
dispatch
|
|
374
|
+
apply_pauses
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
if monotonic - last_commit >= COMMIT_INTERVAL
|
|
378
|
+
commit
|
|
379
|
+
last_commit = monotonic
|
|
380
|
+
end
|
|
381
|
+
rescue StandardError => e
|
|
382
|
+
@logger.error("Kafka consumer loop error: #{e.class}: #{e.message}")
|
|
383
|
+
Kernel.sleep(0.1)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def stop_polling
|
|
389
|
+
@poll_stop = true
|
|
390
|
+
thread = @poll_thread
|
|
391
|
+
@poll_thread = nil
|
|
392
|
+
thread&.join unless thread == Thread.current
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def close_consumer
|
|
396
|
+
consumer = @consumer
|
|
397
|
+
return if consumer.nil?
|
|
398
|
+
|
|
399
|
+
begin
|
|
400
|
+
consumer.close
|
|
401
|
+
rescue StandardError => e
|
|
402
|
+
@logger.debug("Closing the Kafka consumer failed: #{e.message}")
|
|
403
|
+
ensure
|
|
404
|
+
@mutex.synchronize { @consumer = nil }
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Buffers a polled record in its partition.
|
|
409
|
+
def accept(message)
|
|
410
|
+
@mutex.synchronize do
|
|
411
|
+
return if @stopping
|
|
412
|
+
|
|
413
|
+
pair = [message.topic, message.partition]
|
|
414
|
+
# A record of a partition revoked since; its new owner reads it.
|
|
415
|
+
return unless @assigned[pair]
|
|
416
|
+
|
|
417
|
+
ps = @parts[pair] ||= PartitionState.new(topic: message.topic, partition: message.partition, retry: message.topic == topics.retry,
|
|
418
|
+
tracker: Kafka::OffsetTracker.new, backlog: [], in_flight: 0, want_paused: false,
|
|
419
|
+
paused: false, held: nil, committed: -1)
|
|
420
|
+
|
|
421
|
+
ps.backlog << Record.new(message, Kafka.decode_headers(message.headers)) if ps.tracker.add(message.offset)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Starts buffered records while instance and partition slots are free and holds retry records
|
|
426
|
+
# that are not due. A partition with records waiting here is to be paused, so the consumer keeps
|
|
427
|
+
# polling (and stays in the group) without buffering more.
|
|
428
|
+
def dispatch_locked
|
|
429
|
+
return if @stopping || @consumer.nil?
|
|
430
|
+
|
|
431
|
+
states = @parts.values.sort_by(&:key)
|
|
432
|
+
return if states.empty?
|
|
433
|
+
|
|
434
|
+
now = Time.now
|
|
435
|
+
start = @cursor % states.size
|
|
436
|
+
@cursor += 1
|
|
437
|
+
|
|
438
|
+
states.size.times do |i|
|
|
439
|
+
ps = states[(start + i) % states.size]
|
|
440
|
+
|
|
441
|
+
while @running < @options.max_parallel_jobs && ps.in_flight < @settings.max_in_flight_per_partition && !ps.backlog.empty?
|
|
442
|
+
record = ps.backlog.first
|
|
443
|
+
|
|
444
|
+
if ps.retry
|
|
445
|
+
due = Kafka.not_before(record.headers)
|
|
446
|
+
|
|
447
|
+
if due && due > now
|
|
448
|
+
hold(ps, record, due)
|
|
449
|
+
break
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
release_hold(ps)
|
|
454
|
+
ps.backlog.shift
|
|
455
|
+
ps.in_flight += 1
|
|
456
|
+
@running += 1
|
|
457
|
+
|
|
458
|
+
next if post(ps, record)
|
|
459
|
+
|
|
460
|
+
# The pool is closed: the worker is stopping and the record is left to the next owner.
|
|
461
|
+
ps.in_flight -= 1
|
|
462
|
+
@running -= 1
|
|
463
|
+
break
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
ps.want_paused = !ps.backlog.empty?
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def post(ps, record)
|
|
471
|
+
delivery = build_delivery(ps, record)
|
|
472
|
+
source = Source.new(record.message.topic)
|
|
473
|
+
|
|
474
|
+
@pool.post(delivery) do |item|
|
|
475
|
+
@processor.handle(item, source)
|
|
476
|
+
ensure
|
|
477
|
+
release(ps)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def build_delivery(ps, record)
|
|
482
|
+
message = record.message
|
|
483
|
+
|
|
484
|
+
Delivery.new(
|
|
485
|
+
body: message.payload.to_s,
|
|
486
|
+
headers: record.headers,
|
|
487
|
+
correlation_id: nil,
|
|
488
|
+
redelivered: false,
|
|
489
|
+
ack: -> { mark_done(ps, message.offset) },
|
|
490
|
+
nack: ->(requeue) { requeue ? write_back(ps, record) : dead_letter_raw(ps, record, "rejected by the worker") },
|
|
491
|
+
reject: ->(reason) { dead_letter_raw(ps, record, reason) },
|
|
492
|
+
schedule_retry: ->(request) { produce_retry(record, request) }
|
|
493
|
+
)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Frees the instance and partition slot of a finished record.
|
|
497
|
+
def release(ps)
|
|
498
|
+
@mutex.synchronize do
|
|
499
|
+
ps.in_flight -= 1
|
|
500
|
+
@running -= 1
|
|
501
|
+
dispatch_locked
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Lets the record's offset be committed. A partition revoked meanwhile is ignored: its new owner
|
|
506
|
+
# runs the record again.
|
|
507
|
+
def mark_done(ps, offset)
|
|
508
|
+
@mutex.synchronize do
|
|
509
|
+
ps.tracker.done(offset) if @parts[ps.key].equal?(ps)
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Keeps the head of a retry partition until it is due, sending the occurrence's job heartbeats
|
|
514
|
+
# meanwhile. The polling loop dispatches it once it is due.
|
|
515
|
+
def hold(ps, record, due)
|
|
516
|
+
return if ps.held && ps.held.offset == record.message.offset
|
|
517
|
+
|
|
518
|
+
release_hold(ps)
|
|
519
|
+
stop = AbortSignal.new
|
|
520
|
+
ps.held = Held.new(record.message.offset, stop)
|
|
521
|
+
|
|
522
|
+
job = Processor.parse_job(record.message.payload)
|
|
523
|
+
occurrence_id = Processor.parse_occurrence_id(record.headers, nil)
|
|
524
|
+
return if job.nil? || occurrence_id.nil?
|
|
525
|
+
|
|
526
|
+
run = JobRun.new(@processor, job, occurrence_id, Processor.parse_retry_count(record.headers))
|
|
527
|
+
|
|
528
|
+
Thread.new do
|
|
529
|
+
Thread.current.name = "milvaion-kafka-hold"
|
|
530
|
+
|
|
531
|
+
unless stop.aborted?
|
|
532
|
+
run.status(Status::RUNNING, reliable: false)
|
|
533
|
+
@processor.heartbeat_while_waiting(run, due, stop)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def release_hold(ps)
|
|
539
|
+
held = ps.held
|
|
540
|
+
return if held.nil?
|
|
541
|
+
|
|
542
|
+
ps.held = nil
|
|
543
|
+
held.stop.trigger(AbortReason::SHUTDOWN, "released")
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Writes an interrupted job back to its own topic, the counterpart of a RabbitMQ requeue.
|
|
547
|
+
def write_back(ps, record)
|
|
548
|
+
message = record.message
|
|
549
|
+
produce_reliably(topic: message.topic, payload: message.payload, key: message.key, headers: Kafka.encode_headers(record.headers))
|
|
550
|
+
mark_done(ps, message.offset)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Forwards a record the worker cannot process to the dead letter topic unchanged.
|
|
554
|
+
def dead_letter_raw(ps, record, reason)
|
|
555
|
+
message = record.message
|
|
556
|
+
headers = record.headers.merge(Kafka::HEADER_ORIGINAL_TOPIC => message.topic, Kafka::HEADER_DLQ_REASON => reason.to_s)
|
|
557
|
+
key = Processor.parse_occurrence_id(record.headers, nil) || message.key
|
|
558
|
+
|
|
559
|
+
produce_reliably(topic: topics.dlq, payload: message.payload, key: key, headers: Kafka.encode_headers(headers))
|
|
560
|
+
mark_done(ps, message.offset)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Writes the next attempt to the retry topic, due at request.not_before.
|
|
564
|
+
def produce_retry(record, request)
|
|
565
|
+
headers = record.headers.merge(
|
|
566
|
+
Kafka::HEADER_OCCURRENCE_ID => request.occurrence_id,
|
|
567
|
+
Kafka::HEADER_CORRELATION_ID => request.occurrence_id,
|
|
568
|
+
Kafka::HEADER_RETRY_COUNT => request.retry_count,
|
|
569
|
+
Kafka::HEADER_MAX_RETRIES => request.max_retries,
|
|
570
|
+
Kafka::HEADER_NOT_BEFORE => Kafka.unix_ms(request.not_before)
|
|
571
|
+
)
|
|
572
|
+
key = request.job_id.to_s.empty? ? record.message.key : request.job_id
|
|
573
|
+
|
|
574
|
+
produce(topic: topics.retry, payload: record.message.payload, key: key, headers: Kafka.encode_headers(headers))
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Applies the pause state dispatch decided. On the polling thread only.
|
|
578
|
+
def apply_pauses
|
|
579
|
+
pause = []
|
|
580
|
+
resume = []
|
|
581
|
+
|
|
582
|
+
@mutex.synchronize do
|
|
583
|
+
@parts.each_value do |ps|
|
|
584
|
+
next if ps.want_paused == ps.paused
|
|
585
|
+
|
|
586
|
+
ps.paused = ps.want_paused
|
|
587
|
+
(ps.paused ? pause : resume) << ps.key
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
@consumer.pause(partition_list(pause)) unless pause.empty?
|
|
592
|
+
@consumer.resume(partition_list(resume)) unless resume.empty?
|
|
593
|
+
rescue Rdkafka::RdkafkaError => e
|
|
594
|
+
@logger.debug("Pausing or resuming Kafka partitions failed: #{e.message}")
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def pause_everything
|
|
598
|
+
pairs = @mutex.synchronize { @assigned.keys }
|
|
599
|
+
@consumer.pause(partition_list(pairs)) unless pairs.empty?
|
|
600
|
+
rescue Rdkafka::RdkafkaError => e
|
|
601
|
+
@logger.debug("Pausing the Kafka partitions failed: #{e.message}")
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Commits the finished position of every partition (or only of the given [topic, partition]
|
|
605
|
+
# pairs) that moved.
|
|
606
|
+
def commit(only = nil)
|
|
607
|
+
offsets = @mutex.synchronize do
|
|
608
|
+
@parts.each_value.filter_map do |ps|
|
|
609
|
+
next if only && !only.include?(ps.key)
|
|
610
|
+
|
|
611
|
+
position = ps.tracker.commit_position
|
|
612
|
+
[ps, position] if position && position > ps.committed
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
consumer = @consumer
|
|
617
|
+
return if offsets.empty? || consumer.nil?
|
|
618
|
+
|
|
619
|
+
list = Rdkafka::Consumer::TopicPartitionList.new
|
|
620
|
+
offsets.group_by { |ps, _| ps.topic }.each do |topic, entries|
|
|
621
|
+
list.add_topic_and_partitions_with_offsets(topic, entries.to_h { |ps, position| [ps.partition, position] })
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
consumer.commit(list, false)
|
|
625
|
+
|
|
626
|
+
@mutex.synchronize do
|
|
627
|
+
offsets.each { |ps, position| ps.committed = [ps.committed, position].max }
|
|
628
|
+
end
|
|
629
|
+
rescue Rdkafka::RdkafkaError, Rdkafka::ClosedConsumerError => e
|
|
630
|
+
@logger.warn("Kafka offset commit failed; will retry: #{e.message}")
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def partition_pairs(list)
|
|
634
|
+
list.to_h.flat_map do |topic, partitions|
|
|
635
|
+
Array(partitions).map { |p| [topic.to_s, p.respond_to?(:partition) ? p.partition : p] }
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def partition_list(pairs)
|
|
640
|
+
list = Rdkafka::Consumer::TopicPartitionList.new
|
|
641
|
+
pairs.group_by(&:first).each { |topic, entries| list.add_topic(topic, entries.map(&:last)) }
|
|
642
|
+
list
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def format_pairs(pairs)
|
|
646
|
+
pairs.map { |topic, partition| "#{topic}[#{partition}]" }.join(", ")
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
end
|