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.
@@ -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