karafka 2.6.0.beta1 → 2.6.0.rc1

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.
Files changed (212) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -27
  3. data/certs/expired.txt +2 -0
  4. data/config/locales/errors.yml +5 -0
  5. data/karafka.gemspec +1 -1
  6. data/lib/active_job/queue_adapters/karafka_adapter.rb +1 -1
  7. data/lib/karafka/active_job/current_attributes/loading.rb +1 -1
  8. data/lib/karafka/active_job/current_attributes.rb +4 -2
  9. data/lib/karafka/active_job/dispatcher.rb +1 -2
  10. data/lib/karafka/admin/consumer_groups.rb +8 -2
  11. data/lib/karafka/admin/replication.rb +6 -3
  12. data/lib/karafka/admin/topics.rb +32 -12
  13. data/lib/karafka/admin.rb +155 -59
  14. data/lib/karafka/app.rb +1 -1
  15. data/lib/karafka/base_consumer.rb +33 -5
  16. data/lib/karafka/cli/console.rb +0 -1
  17. data/lib/karafka/cli/help.rb +0 -1
  18. data/lib/karafka/cli/info.rb +0 -1
  19. data/lib/karafka/cli/install.rb +0 -1
  20. data/lib/karafka/cli/server.rb +0 -1
  21. data/lib/karafka/cli/swarm.rb +0 -1
  22. data/lib/karafka/cli.rb +2 -2
  23. data/lib/karafka/connection/client.rb +44 -7
  24. data/lib/karafka/connection/listener.rb +3 -3
  25. data/lib/karafka/connection/manager.rb +0 -1
  26. data/lib/karafka/connection/pauses_manager.rb +17 -0
  27. data/lib/karafka/connection/proxy.rb +45 -0
  28. data/lib/karafka/connection/status.rb +0 -1
  29. data/lib/karafka/instrumentation/assignments_tracker.rb +34 -0
  30. data/lib/karafka/instrumentation/callbacks/consumer_groups/decorator.rb +19 -0
  31. data/lib/karafka/instrumentation/callbacks/consumer_groups/error.rb +0 -1
  32. data/lib/karafka/instrumentation/callbacks/consumer_groups/statistics.rb +3 -2
  33. data/lib/karafka/instrumentation/critical_errors_listener.rb +85 -0
  34. data/lib/karafka/instrumentation/logger_listener.rb +2 -0
  35. data/lib/karafka/instrumentation/monitor.rb +2 -3
  36. data/lib/karafka/instrumentation/vendors/appsignal/errors_listener.rb +0 -1
  37. data/lib/karafka/instrumentation/vendors/appsignal/metrics_listener.rb +0 -1
  38. data/lib/karafka/instrumentation/vendors/datadog/logger_listener.rb +4 -3
  39. data/lib/karafka/instrumentation/vendors/datadog/metrics_listener.rb +0 -2
  40. data/lib/karafka/instrumentation/vendors/kubernetes/liveness_listener.rb +181 -18
  41. data/lib/karafka/pro/admin/recovery/errors.rb +0 -1
  42. data/lib/karafka/pro/admin/recovery.rb +6 -4
  43. data/lib/karafka/pro/base_consumer.rb +5 -5
  44. data/lib/karafka/pro/cleaner/messages/metadata.rb +0 -1
  45. data/lib/karafka/pro/cli/parallel_segments/base.rb +1 -2
  46. data/lib/karafka/pro/connection/manager.rb +7 -4
  47. data/lib/karafka/pro/encryption/messages/middleware.rb +5 -0
  48. data/lib/karafka/pro/encryption/messages/parser.rb +4 -0
  49. data/lib/karafka/pro/instrumentation/callbacks/consumer_groups/decorator.rb +63 -0
  50. data/lib/karafka/pro/instrumentation/consumer_groups/lag_compensation/compensator.rb +120 -0
  51. data/lib/karafka/pro/instrumentation/consumer_groups/lag_compensation/fetcher.rb +88 -0
  52. data/lib/karafka/pro/instrumentation/consumer_groups/lag_compensation/refresher.rb +185 -0
  53. data/lib/karafka/pro/instrumentation/consumer_groups/lag_compensation/registry.rb +102 -0
  54. data/lib/karafka/pro/instrumentation/performance_tracker.rb +40 -6
  55. data/lib/karafka/pro/iterator/tpl_builder.rb +27 -7
  56. data/lib/karafka/pro/iterator.rb +22 -7
  57. data/lib/karafka/pro/loader.rb +12 -0
  58. data/lib/karafka/pro/processing/consumer_groups/adaptive_iterator/consumer.rb +0 -3
  59. data/lib/karafka/pro/processing/consumer_groups/adaptive_iterator/tracker.rb +2 -4
  60. data/lib/karafka/pro/processing/consumer_groups/collapser.rb +6 -9
  61. data/lib/karafka/pro/processing/consumer_groups/coordinator.rb +1 -4
  62. data/lib/karafka/pro/processing/consumer_groups/coordinators/errors_tracker.rb +0 -3
  63. data/lib/karafka/pro/processing/consumer_groups/coordinators/filters_applier.rb +21 -10
  64. data/lib/karafka/pro/processing/consumer_groups/coordinators/virtual_offset_manager.rb +4 -4
  65. data/lib/karafka/pro/processing/consumer_groups/expansions_selector.rb +0 -3
  66. data/lib/karafka/pro/processing/consumer_groups/filters/actions.rb +84 -0
  67. data/lib/karafka/pro/processing/consumer_groups/filters/base.rb +5 -6
  68. data/lib/karafka/pro/processing/consumer_groups/filters/delayer.rb +2 -5
  69. data/lib/karafka/pro/processing/consumer_groups/filters/expirer.rb +1 -4
  70. data/lib/karafka/pro/processing/consumer_groups/filters/inline_insights_delayer.rb +1 -4
  71. data/lib/karafka/pro/processing/consumer_groups/filters/throttler.rb +2 -5
  72. data/lib/karafka/pro/processing/consumer_groups/filters/virtual_limiter.rb +0 -3
  73. data/lib/karafka/pro/processing/consumer_groups/jobs/consume_non_blocking.rb +2 -3
  74. data/lib/karafka/pro/processing/consumer_groups/jobs/eofed_non_blocking.rb +0 -2
  75. data/lib/karafka/pro/processing/consumer_groups/jobs/periodic.rb +2 -3
  76. data/lib/karafka/pro/processing/consumer_groups/jobs/periodic_non_blocking.rb +0 -2
  77. data/lib/karafka/pro/processing/consumer_groups/jobs/revoked_non_blocking.rb +0 -3
  78. data/lib/karafka/pro/processing/consumer_groups/jobs_builder.rb +5 -4
  79. data/lib/karafka/pro/processing/consumer_groups/offset_metadata/consumer.rb +3 -7
  80. data/lib/karafka/pro/processing/consumer_groups/offset_metadata/fetcher.rb +8 -10
  81. data/lib/karafka/pro/processing/consumer_groups/offset_metadata/listener.rb +0 -3
  82. data/lib/karafka/pro/processing/consumer_groups/parallel_segments/filters/base.rb +3 -5
  83. data/lib/karafka/pro/processing/consumer_groups/parallel_segments/filters/default.rb +0 -3
  84. data/lib/karafka/pro/processing/consumer_groups/parallel_segments/filters/mom.rb +0 -3
  85. data/lib/karafka/pro/processing/consumer_groups/partitioner.rb +2 -5
  86. data/lib/karafka/pro/processing/consumer_groups/periodic_job/consumer.rb +2 -5
  87. data/lib/karafka/pro/processing/consumer_groups/piping/consumer.rb +2 -5
  88. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/dlq_ftr_lrj_mom.rb +10 -9
  89. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/dlq_ftr_lrj_mom_vp.rb +11 -10
  90. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/dlq_ftr_mom.rb +0 -3
  91. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/dlq_ftr_mom_vp.rb +0 -3
  92. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/dlq_lrj_mom.rb +8 -7
  93. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/dlq_lrj_mom_vp.rb +9 -8
  94. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/dlq_mom.rb +5 -8
  95. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/dlq_mom_vp.rb +3 -6
  96. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/ftr_lrj_mom.rb +4 -7
  97. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/ftr_lrj_mom_vp.rb +6 -9
  98. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/ftr_mom.rb +3 -7
  99. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/ftr_mom_vp.rb +4 -8
  100. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/lrj_mom.rb +3 -6
  101. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/lrj_mom_vp.rb +4 -7
  102. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/mom.rb +2 -5
  103. data/lib/karafka/pro/processing/consumer_groups/strategies/aj/mom_vp.rb +3 -6
  104. data/lib/karafka/pro/processing/consumer_groups/strategies/base.rb +0 -3
  105. data/lib/karafka/pro/processing/consumer_groups/strategies/default.rb +56 -33
  106. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/default.rb +27 -14
  107. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/ftr.rb +0 -3
  108. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/ftr_lrj.rb +4 -7
  109. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/ftr_lrj_mom.rb +5 -8
  110. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/ftr_lrj_mom_vp.rb +5 -8
  111. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/ftr_lrj_vp.rb +3 -7
  112. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/ftr_mom.rb +0 -3
  113. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/ftr_mom_vp.rb +0 -3
  114. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/ftr_vp.rb +0 -3
  115. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/lrj.rb +2 -5
  116. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/lrj_mom.rb +0 -3
  117. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/lrj_mom_vp.rb +0 -3
  118. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/lrj_vp.rb +3 -6
  119. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/mom.rb +0 -3
  120. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/mom_vp.rb +3 -6
  121. data/lib/karafka/pro/processing/consumer_groups/strategies/dlq/vp.rb +65 -8
  122. data/lib/karafka/pro/processing/consumer_groups/strategies/ftr/default.rb +0 -4
  123. data/lib/karafka/pro/processing/consumer_groups/strategies/ftr/vp.rb +2 -5
  124. data/lib/karafka/pro/processing/consumer_groups/strategies/lrj/default.rb +0 -3
  125. data/lib/karafka/pro/processing/consumer_groups/strategies/lrj/ftr.rb +3 -6
  126. data/lib/karafka/pro/processing/consumer_groups/strategies/lrj/ftr_mom.rb +4 -7
  127. data/lib/karafka/pro/processing/consumer_groups/strategies/lrj/ftr_mom_vp.rb +4 -8
  128. data/lib/karafka/pro/processing/consumer_groups/strategies/lrj/ftr_vp.rb +3 -6
  129. data/lib/karafka/pro/processing/consumer_groups/strategies/lrj/mom.rb +2 -5
  130. data/lib/karafka/pro/processing/consumer_groups/strategies/lrj/mom_vp.rb +3 -7
  131. data/lib/karafka/pro/processing/consumer_groups/strategies/lrj/vp.rb +2 -5
  132. data/lib/karafka/pro/processing/consumer_groups/strategies/mom/default.rb +0 -3
  133. data/lib/karafka/pro/processing/consumer_groups/strategies/mom/ftr.rb +0 -3
  134. data/lib/karafka/pro/processing/consumer_groups/strategies/mom/ftr_vp.rb +0 -3
  135. data/lib/karafka/pro/processing/consumer_groups/strategies/mom/vp.rb +0 -3
  136. data/lib/karafka/pro/processing/consumer_groups/strategies/vp/default.rb +11 -6
  137. data/lib/karafka/pro/processing/consumer_groups/strategy_selector.rb +0 -3
  138. data/lib/karafka/pro/processing/consumer_groups/subscription_groups_coordinator.rb +0 -3
  139. data/lib/karafka/pro/processing/consumer_groups/virtual_partitions/distributors/balanced.rb +0 -3
  140. data/lib/karafka/pro/processing/consumer_groups/virtual_partitions/distributors/base.rb +2 -4
  141. data/lib/karafka/pro/processing/consumer_groups/virtual_partitions/distributors/consistent.rb +0 -3
  142. data/lib/karafka/pro/processing/jobs_queue.rb +4 -1
  143. data/lib/karafka/pro/processing/schedulers/base.rb +4 -2
  144. data/lib/karafka/pro/processing/schedulers/default.rb +2 -2
  145. data/lib/karafka/pro/recurring_tasks/consumer.rb +9 -2
  146. data/lib/karafka/pro/recurring_tasks/contracts/config.rb +0 -1
  147. data/lib/karafka/pro/recurring_tasks/executor.rb +25 -13
  148. data/lib/karafka/pro/routing/features/consumer_groups/direct_assignments/subscription_group.rb +0 -2
  149. data/lib/karafka/pro/routing/features/consumer_groups/long_running_job.rb +0 -2
  150. data/lib/karafka/pro/routing/features/consumer_groups/multiplexing/proxy.rb +2 -2
  151. data/lib/karafka/pro/routing/features/consumer_groups/non_blocking_job.rb +0 -2
  152. data/lib/karafka/pro/routing/features/consumer_groups/offset_metadata/topic.rb +0 -6
  153. data/lib/karafka/pro/routing/features/consumer_groups/parallel_segments/consumer_group.rb +5 -4
  154. data/lib/karafka/pro/routing/features/consumer_groups/patterns/config.rb +0 -6
  155. data/lib/karafka/pro/routing/features/consumer_groups/patterns/consumer_group.rb +2 -1
  156. data/lib/karafka/pro/routing/features/consumer_groups/patterns/contracts/pattern.rb +0 -1
  157. data/lib/karafka/pro/routing/features/consumer_groups/patterns/detector.rb +2 -1
  158. data/lib/karafka/pro/routing/features/consumer_groups/patterns/pattern.rb +2 -1
  159. data/lib/karafka/pro/routing/features/consumer_groups/patterns/topics.rb +8 -8
  160. data/lib/karafka/pro/routing/features/consumer_groups/periodic_job/topic.rb +5 -5
  161. data/lib/karafka/pro/routing/features/consumer_groups/recurring_tasks/builder.rb +33 -13
  162. data/lib/karafka/pro/routing/features/consumer_groups/scheduled_messages/builder.rb +52 -28
  163. data/lib/karafka/pro/routing/features/consumer_groups/scheduled_messages/topic.rb +2 -1
  164. data/lib/karafka/pro/routing/features/consumer_groups/virtual_partitions/topic.rb +6 -5
  165. data/lib/karafka/pro/scheduled_messages/consumer.rb +7 -1
  166. data/lib/karafka/pro/scheduled_messages/schema_validator.rb +6 -1
  167. data/lib/karafka/pro/swarm/liveness_listener.rb +118 -13
  168. data/lib/karafka/pro.rb +0 -6
  169. data/lib/karafka/processing/consumer_groups/coordinator.rb +4 -5
  170. data/lib/karafka/processing/consumer_groups/coordinators_buffer.rb +6 -2
  171. data/lib/karafka/processing/consumer_groups/executor.rb +4 -4
  172. data/lib/karafka/processing/consumer_groups/executors_buffer.rb +2 -4
  173. data/lib/karafka/processing/consumer_groups/expansions_selector.rb +0 -2
  174. data/lib/karafka/processing/consumer_groups/inline_insights/consumer.rb +0 -2
  175. data/lib/karafka/processing/consumer_groups/inline_insights/listener.rb +0 -2
  176. data/lib/karafka/processing/consumer_groups/inline_insights/tracker.rb +14 -16
  177. data/lib/karafka/processing/consumer_groups/jobs/consume.rb +2 -5
  178. data/lib/karafka/processing/consumer_groups/jobs/eofed.rb +2 -3
  179. data/lib/karafka/processing/consumer_groups/jobs/idle.rb +2 -2
  180. data/lib/karafka/processing/consumer_groups/jobs/revoked.rb +2 -4
  181. data/lib/karafka/processing/consumer_groups/jobs/shutdown.rb +2 -2
  182. data/lib/karafka/processing/consumer_groups/partitioner.rb +3 -6
  183. data/lib/karafka/processing/consumer_groups/strategies/aj_dlq_mom.rb +5 -2
  184. data/lib/karafka/processing/consumer_groups/strategies/aj_mom.rb +2 -4
  185. data/lib/karafka/processing/consumer_groups/strategies/base.rb +3 -5
  186. data/lib/karafka/processing/consumer_groups/strategies/default.rb +43 -13
  187. data/lib/karafka/processing/consumer_groups/strategies/dlq.rb +5 -2
  188. data/lib/karafka/processing/consumer_groups/strategies/dlq_mom.rb +5 -2
  189. data/lib/karafka/processing/consumer_groups/strategies/mom.rb +2 -3
  190. data/lib/karafka/processing/consumer_groups/strategy_selector.rb +0 -2
  191. data/lib/karafka/processing/coordinator.rb +0 -2
  192. data/lib/karafka/processing/coordinators_buffer.rb +0 -2
  193. data/lib/karafka/processing/partitioner.rb +0 -2
  194. data/lib/karafka/processing/strategy_selector.rb +0 -2
  195. data/lib/karafka/processing/worker.rb +14 -6
  196. data/lib/karafka/processing/workers_pool.rb +21 -4
  197. data/lib/karafka/railtie.rb +0 -1
  198. data/lib/karafka/routing/contracts/routing.rb +2 -2
  199. data/lib/karafka/routing/features/consumer_groups/active_job/topic.rb +6 -5
  200. data/lib/karafka/routing/features/consumer_groups/manual_offset_management/topic.rb +2 -2
  201. data/lib/karafka/routing/features/deserializers/topic.rb +0 -1
  202. data/lib/karafka/routing/subscription_group.rb +1 -1
  203. data/lib/karafka/routing/topic.rb +4 -4
  204. data/lib/karafka/routing/topics.rb +21 -1
  205. data/lib/karafka/server.rb +9 -0
  206. data/lib/karafka/setup/config.rb +54 -10
  207. data/lib/karafka/setup/config_proxy.rb +2 -1
  208. data/lib/karafka/setup/contracts/config.rb +14 -0
  209. data/lib/karafka/swarm/node.rb +2 -2
  210. data/lib/karafka/version.rb +1 -2
  211. data/lib/karafka.rb +2 -2
  212. metadata +12 -4
@@ -65,6 +65,51 @@ module Karafka
65
65
  end
66
66
  end
67
67
 
68
+ # Resolves offsets of many partitions with one batched `ListOffsets` request instead of one
69
+ # roundtrip per partition. This is the same broker query as `#query_watermark_offsets`,
70
+ # only batched, so it is sensitive to the same latencies and leader-election hiccups and we
71
+ # recover from them the same way and with the same settings.
72
+ #
73
+ # Named after `Admin::Topics#read_partition_offsets` (same specs in, same offsets out) and
74
+ # **not** after the underlying `#list_offsets`, so we do not shadow the rdkafka method of
75
+ # that name: it returns a handle to wait on, while we return the already resolved offsets.
76
+ #
77
+ # @param topic_partition_offsets [Hash{String => Array<Hash>}] topics with arrays of
78
+ # partition offset specs, each with a `:partition` and an `:offset` (`:earliest`,
79
+ # `:latest`, `:max_timestamp` or an integer timestamp in ms)
80
+ # @param isolation_level [Integer, nil] isolation level with which the offsets should be
81
+ # resolved or nil for the librdkafka default (read_uncommitted)
82
+ # @return [Array<Hash>] resolved offsets, each with `:topic`, `:partition` and `:offset`
83
+ # @note The `ListOffsets` admin API resolves `:latest` to the high watermark even with
84
+ # `isolation_level` set to read_committed, unlike `#query_watermark_offsets`, which returns
85
+ # the last stable offset for a read_committed consumer. On a topic with an in-flight
86
+ # transaction `:latest` therefore includes the uncommitted messages. Non-transactional
87
+ # topics are unaffected.
88
+ # @note Specs given as a literal hash need explicit curly braces: a brace-less trailing
89
+ # hash is parsed by Ruby as keyword arguments, which this method (having the
90
+ # `isolation_level:` keyword) would reject as a missing positional argument
91
+ def read_partition_offsets(topic_partition_offsets, isolation_level: nil)
92
+ l_config = proxy_config.query_watermark_offsets
93
+
94
+ with_broker_errors_retry(
95
+ # required to be in seconds, not ms
96
+ wait_time: l_config.wait_time / 1_000.to_f,
97
+ max_attempts: l_config.max_attempts
98
+ ) do
99
+ @wrapped
100
+ .list_offsets(topic_partition_offsets, isolation_level: isolation_level)
101
+ .wait(max_wait_timeout_ms: l_config.timeout)
102
+ .offsets
103
+ rescue Rdkafka::AbstractHandle::WaitTimeoutError
104
+ # Align with the error contract of the per-partition watermark query, so a slow broker
105
+ # is retried here instead of escaping as a handle wait timeout
106
+ raise Rdkafka::RdkafkaError.new(
107
+ Rdkafka::Bindings::RD_KAFKA_RESP_ERR__TIMED_OUT,
108
+ "Error querying offsets of '#{topic_partition_offsets.keys.join(", ")}'"
109
+ )
110
+ end
111
+ end
112
+
68
113
  # Similar to `#query_watermark_offsets`, this method can be sensitive to latency. We handle
69
114
  # this the same way
70
115
  #
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Karafka
4
- # Namespace for Kafka connection related logic
5
4
  module Connection
6
5
  # Listener connection status representation
7
6
  class Status
@@ -14,6 +14,16 @@ module Karafka
14
14
  class AssignmentsTracker
15
15
  include Singleton
16
16
 
17
+ # Upper bound on the number of distinct topics tracked in `@generations`. Generations are
18
+ # intentionally retained after revocation (so they can keep incrementing across
19
+ # reassignments), which means the map only ever grows. With static topologies that is
20
+ # bounded by the routing topic count, but pattern subscriptions inject a fresh, permanent
21
+ # `Routing::Topic` per discovered name, so without a bound the map (and the frozen deep copy
22
+ # built in `#generations`) would grow for the whole process lifetime. When the bound is
23
+ # exceeded we evict the least-recently-assigned topics. It is high enough never to be reached
24
+ # by realistic static or even large dynamic topologies.
25
+ GENERATIONS_TOPICS_LIMIT = 100_000
26
+
17
27
  class << self
18
28
  # @return [Hash{Karafka::Routing::Topic => Array<Integer>}]
19
29
  # @see #current
@@ -74,6 +84,10 @@ module Karafka
74
84
  # reassignment. Revoked partitions remain in the hash with their last generation value.
75
85
  #
76
86
  # @note Returns a frozen deep copy to prevent external mutation
87
+ #
88
+ # @note The map is bounded to `GENERATIONS_TOPICS_LIMIT` topics; once exceeded, the
89
+ # least-recently-assigned topics are evicted. This only ever matters for very large dynamic
90
+ # (pattern-subscribed) topologies - static ones never reach the bound.
77
91
  def generations
78
92
  result = {}
79
93
 
@@ -188,11 +202,31 @@ module Karafka
188
202
  @generations[topic][partition_id] += 1
189
203
  end
190
204
 
205
+ # Move this topic to the end of the insertion-ordered map so it counts as
206
+ # most-recently-assigned for the bounded eviction below (keeps actively reassigned
207
+ # topics, drops genuinely stale ones first).
208
+ @generations[topic] = @generations.delete(topic)
209
+
191
210
  @assignments[topic] += partition_ids
192
211
  @assignments[topic].sort!
193
212
  end
213
+
214
+ evict_stale_generations
194
215
  end
195
216
  end
217
+
218
+ private
219
+
220
+ # Keeps `@generations` bounded by evicting the least-recently-assigned topics once the limit
221
+ # is exceeded. Without this, pattern subscriptions (which keep injecting new permanent topics)
222
+ # would grow the map without bound for the whole process lifetime.
223
+ def evict_stale_generations
224
+ excess = @generations.size - GENERATIONS_TOPICS_LIMIT
225
+
226
+ return unless excess.positive?
227
+
228
+ @generations.keys.first(excess).each { |topic| @generations.delete(topic) }
229
+ end
196
230
  end
197
231
  end
198
232
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Instrumentation
5
+ module Callbacks
6
+ module ConsumerGroups
7
+ # Default statistics decorator used for consumer group statistics decoration.
8
+ #
9
+ # This is a thin subclass (rather than referencing
10
+ # `Karafka::Core::Monitoring::StatisticsDecorator` directly) so it can be swapped out via
11
+ # `config.internal.statistics.consumer_groups.decorator_class` for a custom decorator (for
12
+ # example one that also enriches or corrects specific statistics values) without having to
13
+ # alter the callback that uses it.
14
+ class Decorator < Karafka::Core::Monitoring::StatisticsDecorator
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -4,7 +4,6 @@ module Karafka
4
4
  module Instrumentation
5
5
  # Callbacks used to transport things from rdkafka
6
6
  module Callbacks
7
- # Consumer-group-specific librdkafka callbacks
8
7
  module ConsumerGroups
9
8
  # Callback that kicks in when consumer error occurs and is published in a background thread
10
9
  class Error
@@ -9,7 +9,8 @@ module Karafka
9
9
  # those statistics
10
10
  class Statistics
11
11
  include Helpers::ConfigImporter.new(
12
- monitor: %i[monitor]
12
+ monitor: %i[monitor],
13
+ decorator_class: %i[internal statistics consumer_groups decorator_class]
13
14
  )
14
15
 
15
16
  # @param subscription_group_id [String]
@@ -19,7 +20,7 @@ module Karafka
19
20
  @subscription_group_id = subscription_group_id
20
21
  @group_id = group_id
21
22
  @client_name = client_name
22
- @statistics_decorator = Karafka::Core::Monitoring::StatisticsDecorator.new
23
+ @statistics_decorator = decorator_class.new
23
24
  end
24
25
 
25
26
  # Emits decorated statistics to the monitor
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karafka
4
+ module Instrumentation
5
+ # Listener that watches the `error.occurred` bus for process-critical errors and initiates a
6
+ # graceful shutdown when one appears, regardless of where in the framework it surfaced
7
+ # (consumption flow, after-consume flow, worker, etc.).
8
+ #
9
+ # Process-critical errors (see the `internal.processing.critical_errors` setting) are not
10
+ # something a retry can handle: SystemExit means termination was explicitly requested and
11
+ # NoMemoryError means the VM cannot be trusted to continue. The error containment in the
12
+ # processing flow keeps the affected partition paused and its offsets uncommitted, so after
13
+ # the restart the failed work is redelivered - the stop initiated here is what converts that
14
+ # containment into the actual process replacement.
15
+ #
16
+ # It is auto-subscribed as part of Karafka itself.
17
+ class CriticalErrorsListener
18
+ include Singleton
19
+ include Helpers::ConfigImporter.new(
20
+ critical_errors: %i[internal processing critical_errors]
21
+ )
22
+
23
+ # Sets up the one-shot initiation state
24
+ def initialize
25
+ @mutex = Mutex.new
26
+ @initiated = false
27
+ end
28
+
29
+ # Re-arms the listener when the app (re)starts so in-process restarts (embedded mode,
30
+ # integration flows) can escalate again
31
+ # @param _event [Karafka::Core::Monitoring::Event]
32
+ def on_app_running(_event)
33
+ @mutex.synchronize { @initiated = false }
34
+ end
35
+
36
+ # @param event [Karafka::Core::Monitoring::Event] error.occurred event. Its `:error` key
37
+ # carries the error that was reported.
38
+ def on_error_occurred(event)
39
+ error = event[:error]
40
+
41
+ return unless critical_errors.any? { |type| error.is_a?(type) }
42
+
43
+ # The one-shot flag makes the check-and-spawn atomic: critical errors reported by many
44
+ # consumers at the same moment (or a duplicated bus subscription) spawn exactly one
45
+ # supervising stop thread
46
+ @mutex.synchronize do
47
+ return if @initiated
48
+ # No need to initiate the stop if one is already in motion (shutdown requested before
49
+ # the error surfaced). We deliberately do not use `App.done?` here: it includes the
50
+ # quieting/quiet states, in which the process is parked but alive - a critical error
51
+ # raised by in-flight work during quiet must still escalate to a full stop
52
+ return if Karafka::App.stopping?
53
+ return if Karafka::App.stopped?
54
+ return if Karafka::App.terminated?
55
+
56
+ @initiated = true
57
+
58
+ # `Karafka::Server.stop` supervises the whole shutdown (including the forceful timeout
59
+ # path), so it runs from a dedicated thread: the reporting thread must return so the
60
+ # processing flow can finish and engage the pause that protects the affected partition
61
+ # during the shutdown window
62
+ Thread.new do
63
+ Thread.current.name = "karafka.critical_shutdown"
64
+
65
+ Karafka::Server.stop
66
+ end
67
+ end
68
+ # The spawn itself can fail exactly under the conditions this listener handles (e.g.
69
+ # NoMemoryError, ThreadError at the thread limit). It must never escape: an error raised
70
+ # from an `error.occurred` subscriber would propagate into the reporting flow and could
71
+ # bypass the containment that protects the failed batch. We leave a best-effort fatal
72
+ # trace because in this degenerate state the process will NOT shut down despite the
73
+ # critical error - without it the why would be undiagnosable
74
+ rescue Exception
75
+ begin
76
+ Karafka.logger.fatal(
77
+ "Failed to initiate the critical error shutdown - process continues without it"
78
+ )
79
+ rescue Exception
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -467,6 +467,8 @@ module Karafka
467
467
  when "virtual_partitions.partitioner.error"
468
468
  error "Virtual partitions partitioner error occurred: #{details}"
469
469
  error backtrace
470
+ when "lag_compensation.refresher.error"
471
+ warn "Lag compensation refresher error occurred: #{details}"
470
472
  # This handles any custom errors coming from places like Web-UI, etc
471
473
  else
472
474
  error "#{type} error occurred: #{error.class} - #{details}"
@@ -2,9 +2,8 @@
2
2
 
3
3
  module Karafka
4
4
  module Instrumentation
5
- # Karafka instrumentation monitor that we use to publish events
6
- # By default uses our internal notifications bus but can be used with
7
- # `ActiveSupport::Notifications` as well
5
+ # Karafka instrumentation monitor that we use to publish events. By default uses our internal
6
+ # notifications bus but can be used with `ActiveSupport::Notifications` as well
8
7
  class Monitor < Karafka::Core::Monitoring::Monitor
9
8
  attr_reader :notifications_bus
10
9
 
@@ -5,7 +5,6 @@ require_relative "base"
5
5
  module Karafka
6
6
  module Instrumentation
7
7
  module Vendors
8
- # Namespace for Appsignal instrumentation
9
8
  module Appsignal
10
9
  # Listener for reporting errors from both consumers and producers
11
10
  # Since we have the same API for WaterDrop and Karafka, we can use one listener with
@@ -5,7 +5,6 @@ require_relative "base"
5
5
  module Karafka
6
6
  module Instrumentation
7
7
  module Vendors
8
- # Namespace for Appsignal instrumentation
9
8
  module Appsignal
10
9
  # Listener that ships metrics to Appsignal
11
10
  class MetricsListener < Base
@@ -6,8 +6,7 @@ module Karafka
6
6
  module Vendors
7
7
  # Datadog specific instrumentation
8
8
  module Datadog
9
- # A karafka's logger listener for Datadog
10
- # It depends on the 'ddtrace' gem
9
+ # A karafka's logger listener for Datadog. It depends on the 'ddtrace' gem
11
10
  class LoggerListener
12
11
  include Karafka::Core::Configurable
13
12
  extend Forwardable
@@ -17,7 +16,7 @@ module Karafka
17
16
  # `Datadog::Tracing` client that we should use to trace stuff
18
17
  setting :client
19
18
 
20
- # @see https://docs.datadoghq.com/tracing/trace_collection/dd_libraries/ruby
19
+ # See https://docs.datadoghq.com/tracing/trace_collection/dd_libraries/ruby
21
20
  setting :service_name, default: nil
22
21
 
23
22
  configure
@@ -145,6 +144,8 @@ module Karafka
145
144
  error "Parallel segments partitioner error occurred: #{error}"
146
145
  when "virtual_partitions.partitioner.error"
147
146
  error "Virtual partitions partitioner error occurred: #{error}"
147
+ when "lag_compensation.refresher.error"
148
+ warn "Lag compensation refresher error occurred: #{error}"
148
149
  else
149
150
  error "#{event[:type]} error occurred: #{error}"
150
151
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Karafka
4
4
  module Instrumentation
5
- # Namespace for vendor specific instrumentation
6
5
  module Vendors
7
- # Datadog specific instrumentation
8
6
  module Datadog
9
7
  # Listener that can be used to subscribe to Karafka to receive stats via StatsD
10
8
  # and/or Datadog
@@ -5,7 +5,6 @@ require "karafka/instrumentation/vendors/kubernetes/base_listener"
5
5
  module Karafka
6
6
  module Instrumentation
7
7
  module Vendors
8
- # Namespace for instrumentation related with Kubernetes
9
8
  module Kubernetes
10
9
  # Kubernetes HTTP listener that does not only reply when process is not fully hanging, but
11
10
  # also allows to define max time of processing and looping.
@@ -26,6 +25,21 @@ module Karafka
26
25
  #
27
26
  # @note Please use `Kubernetes::SwarmLivenessListener` when operating in the swarm mode
28
27
  class LivenessListener < BaseListener
28
+ # Default time in ms after which we consider consumption hanging (5 minutes)
29
+ DEFAULT_CONSUMING_TTL = 5 * 60 * 1_000
30
+ # Default max time in ms between polls before the process is considered dead (5 minutes)
31
+ DEFAULT_POLLING_TTL = 5 * 60 * 1_000
32
+ # Multiplier applied to max.poll.interval.ms when deriving the default stability_ttl.
33
+ # 2x gives headroom above the slowest legitimate rebalance phase (cooperative
34
+ # `wait-unassign-to-complete` can approach max.poll.interval.ms).
35
+ STABILITY_TTL_MAX_POLL_MULTIPLIER = 2
36
+
37
+ private_constant(
38
+ :DEFAULT_CONSUMING_TTL,
39
+ :DEFAULT_POLLING_TTL,
40
+ :STABILITY_TTL_MAX_POLL_MULTIPLIER
41
+ )
42
+
29
43
  # @param hostname [String, nil] hostname or nil to bind on all
30
44
  # @param port [Integer] TCP port on which we want to run our HTTP status server
31
45
  # @param consuming_ttl [Integer] time in ms after which we consider consumption hanging.
@@ -33,12 +47,36 @@ module Karafka
33
47
  # process as hanging
34
48
  # @param polling_ttl [Integer] max time in ms for polling. If polling (any) does not
35
49
  # happen that often, process should be considered dead.
36
- # @note The default TTL matches the default `max.poll.interval.ms`
50
+ # @param stability_ttl [Integer, nil] max time in ms a subscription group can remain in
51
+ # the same tracked non-"steady" librdkafka `cgrp.join_state` (e.g. `wait-join`,
52
+ # `wait-assn`, `wait-sync`) before the process is considered unhealthy. When nil
53
+ # (default), it is derived lazily on first health evaluation as the maximum
54
+ # `max.poll.interval.ms` across all active subscription groups, multiplied by 2,
55
+ # which keeps the threshold safe relative to the user's actual poll intervals rather
56
+ # than a fixed constant. Per subscription group overrides of `max.poll.interval.ms`
57
+ # set via the routing DSL are respected - the largest value wins. When no subscription
58
+ # groups are available (routes not drawn), the root `Karafka::App.config.kafka` value
59
+ # is used instead (with librdkafka's default applied). `wait-metadata`
60
+ # and `init` are
61
+ # excluded from tracking and additionally clear any stale prior tracking for the
62
+ # subscription group: `wait-metadata` appears in normal scenarios (auto-create waits,
63
+ # unmatched pattern subscriptions, and as the group leader's post-JoinGroup state while
64
+ # fetching cluster metadata to compute the partition assignment); `init` indicates a
65
+ # client reset. Both states are covered by `polling_ttl` for genuine freezes.
66
+ # The timer resets on every join_state transition between tracked states because any
67
+ # change indicates the join protocol is still progressing; only a consumer frozen in
68
+ # the same non-steady state continuously triggers the alarm. A consumer cycling
69
+ # rapidly between non-steady states (e.g. wait-join -> wait-assn -> wait-join) will
70
+ # not trip this check - it only catches consumers frozen in a single join state.
71
+ # Requires `statistics.interval.ms` to be configured in the Kafka client settings;
72
+ # without it `statistics.emitted` never fires, tracking hashes remain empty, and this
73
+ # check has no effect (no misreporting).
37
74
  def initialize(
38
75
  hostname: nil,
39
76
  port: 3000,
40
- consuming_ttl: 5 * 60 * 1_000,
41
- polling_ttl: 5 * 60 * 1_000
77
+ consuming_ttl: DEFAULT_CONSUMING_TTL,
78
+ polling_ttl: DEFAULT_POLLING_TTL,
79
+ stability_ttl: nil
42
80
  )
43
81
  # If this is set to a symbol, it indicates unrecoverable error like fencing
44
82
  # While fencing can be partial (for one of the SGs), we still should consider this
@@ -47,9 +85,21 @@ module Karafka
47
85
  @unrecoverable = false
48
86
  @polling_ttl = polling_ttl
49
87
  @consuming_ttl = consuming_ttl
88
+ # When nil, resolved lazily via #stability_ttl on first health evaluation because
89
+ # the default derives from the routing subscription groups, which may not be drawn
90
+ # yet when the listener is constructed in karafka.rb
91
+ @stability_ttl = stability_ttl
50
92
  @mutex = Mutex.new
51
93
  @pollings = {}
52
94
  @consumptions = {}
95
+ # Maps subscription_group_id => last observed cgrp.join_state string.
96
+ # Used to detect state changes so the stuck timer resets on any transition.
97
+ # Both hashes are only populated by on_statistics_emitted, which requires
98
+ # statistics.interval.ms to be set. Without it they remain empty and the
99
+ # stability flag in evaluate_ttl_flags is always false - no misreporting.
100
+ @join_states = {}
101
+ # Maps subscription_group_id => monotonic_now when the current non-steady state began.
102
+ @instabilities = {}
53
103
  super(hostname: hostname, port: port)
54
104
  end
55
105
 
@@ -70,6 +120,43 @@ module Karafka
70
120
  mark_polling_tick
71
121
  end
72
122
 
123
+ # Track when each subscription group's consumer enters or leaves a non-steady join state.
124
+ # A prolonged non-steady state (e.g., wait-join, wait-assn caused by a broker stuck in
125
+ # CompletingRebalance) is reported as unhealthy.
126
+ #
127
+ # `steady`, `init`, and `wait-metadata` all clear any stale tracking for the subscription
128
+ # group rather than skip the event. Clearing on `wait-metadata` matters because the
129
+ # state legitimately appears mid-join (leader fetching cluster metadata to compute
130
+ # assignment), during auto-create waits, and on unmatched pattern subscriptions - if we
131
+ # only `return` from these the previous non-steady timer keeps running and eventually
132
+ # trips a false positive. Clearing on `init` covers client resets / subscription-group
133
+ # removal. Genuine freezes in these states are covered by `polling_ttl`.
134
+ #
135
+ # The stuck timer resets on every transition between tracked states because any change
136
+ # indicates the join protocol is still progressing. Only a consumer frozen in the same
137
+ # non-steady state continuously for stability_ttl is flagged. A consumer cycling
138
+ # rapidly between non-steady states (e.g. wait-join -> wait-assn -> wait-join) will not
139
+ # trip this alarm - only a frozen-in-one-state consumer is caught.
140
+ # @param event [Karafka::Core::Monitoring::Event]
141
+ def on_statistics_emitted(event)
142
+ cgrp = event[:statistics]&.dig("cgrp")
143
+ return unless cgrp
144
+
145
+ sg_id = event[:subscription_group_id]
146
+ join_state = cgrp["join_state"]
147
+ return unless join_state
148
+
149
+ synchronize do
150
+ if join_state == "steady" || join_state == "init" || join_state == "wait-metadata"
151
+ @instabilities.delete(sg_id)
152
+ @join_states.delete(sg_id)
153
+ elsif @join_states[sg_id] != join_state
154
+ @join_states[sg_id] = join_state
155
+ @instabilities[sg_id] = monotonic_now
156
+ end
157
+ end
158
+ end
159
+
73
160
  {
74
161
  consume: :consumed,
75
162
  revoke: :revoked,
@@ -127,30 +214,95 @@ module Karafka
127
214
  clear_polling_tick
128
215
  end
129
216
 
217
+ # Clear instability tracking for a subscription group whose listener finished its fetch
218
+ # loop (dynamic downscale or shutdown). This event fires after the listener's client has
219
+ # already been stopped, so no further statistics can move the join_state back to steady
220
+ # and no late statistics callback can re-create the entry. Without this cleanup a
221
+ # lingering non-steady entry (e.g. a downscale mid-rebalance) would guarantee a false
222
+ # positive once stability_ttl elapses. Genuine shutdown freezes remain covered by
223
+ # polling_ttl.
224
+ # @param event [Karafka::Core::Monitoring::Event]
225
+ def on_connection_listener_after_fetch_loop(event)
226
+ clear_instability_tracking(event[:subscription_group])
227
+ end
228
+
130
229
  # Did we exceed any of the ttls
131
- # @return [String] 204 string if ok, 500 otherwise
230
+ # @return [Boolean] true if all TTLs are within bounds and no unrecoverable error
231
+ # @note When called from inside `status_body`, reuses the cached snapshot so the
232
+ # per-request HTTP response stays internally consistent. When called standalone,
233
+ # takes its own snapshot.
132
234
  def healthy?
133
235
  return false if @unrecoverable
134
- return false if polling_ttl_exceeded?
135
- return false if consuming_ttl_exceeded?
136
236
 
137
- true
237
+ flags = @evaluation_snapshot || evaluate_ttl_flags
238
+ !flags[:polling] && !flags[:consuming] && !flags[:stability]
138
239
  end
139
240
 
140
241
  private
141
242
 
142
- # @return [Boolean] true if the consumer exceeded the polling ttl
143
- def polling_ttl_exceeded?
243
+ # Single-snapshot evaluation of all three TTL flags under one lock. Used by both
244
+ # `healthy?` and `status_body` so the HTTP status code (derived from healthy?) and the
245
+ # error details in the response body are always derived from the same atomic snapshot.
246
+ # @return [Hash{Symbol => Boolean}] flags for polling, consuming and stability
247
+ def evaluate_ttl_flags
144
248
  time = monotonic_now
249
+ synchronize do
250
+ {
251
+ polling: @pollings.values.any? { |tick| (time - tick) > @polling_ttl },
252
+ consuming: @consumptions.values.any? { |tick| (time - tick) > @consuming_ttl },
253
+ stability: @instabilities.values.any? { |start| (time - start) > stability_ttl }
254
+ }
255
+ end
256
+ end
145
257
 
146
- @pollings.values.any? { |tick| (time - tick) > @polling_ttl }
258
+ # @return [Integer] effective stability ttl in ms. Resolved lazily (instead of in
259
+ # `#initialize`) because the default derives from the routing subscription groups,
260
+ # which may not be drawn yet when the listener is constructed in karafka.rb. By the
261
+ # time health is evaluated, routing is final.
262
+ def stability_ttl
263
+ @stability_ttl ||= default_stability_ttl
147
264
  end
148
265
 
149
- # @return [Boolean] true if the consumer exceeded the consuming ttl
150
- def consuming_ttl_exceeded?
151
- time = monotonic_now
266
+ # @return [Integer] default stability_ttl derived from the configured
267
+ # max.poll.interval.ms, multiplied by 2 for headroom above the slowest legitimate
268
+ # rebalance phase. Uses the maximum value across all active subscription groups
269
+ # (their kafka configs already have librdkafka defaults injected), so per
270
+ # subscription group overrides set via the routing DSL are respected. Falls back to
271
+ # the root kafka config when no subscription groups are available.
272
+ def default_stability_ttl
273
+ max_poll_interval = Karafka::App
274
+ .subscription_groups
275
+ .values
276
+ .flatten
277
+ .map { |sg| sg.kafka.fetch(:"max.poll.interval.ms") }
278
+ .max
279
+
280
+ max_poll_interval ||= begin
281
+ # DefaultsInjector#consumer mutates a kafka config hash to ensure
282
+ # max.poll.interval.ms is set (using the same librdkafka default Karafka injects
283
+ # per subscription group). We dup so we don't mutate the global config.
284
+ kafka_config = Karafka::App.config.kafka.dup
285
+ Karafka::Setup::DefaultsInjector.consumer(kafka_config)
286
+ kafka_config.fetch(:"max.poll.interval.ms")
287
+ end
152
288
 
153
- @consumptions.values.any? { |tick| (time - tick) > @consuming_ttl }
289
+ max_poll_interval * STABILITY_TTL_MAX_POLL_MULTIPLIER
290
+ end
291
+
292
+ # Removes join_state tracking for the given subscription group. Used when its listener
293
+ # stops, since no further statistics events will arrive to move the state back to
294
+ # steady.
295
+ # @param subscription_group [Karafka::Routing::SubscriptionGroup, nil] subscription
296
+ # group from the event payload (nil-safe for events emitted without one)
297
+ def clear_instability_tracking(subscription_group)
298
+ return unless subscription_group
299
+
300
+ sg_id = subscription_group.id
301
+
302
+ synchronize do
303
+ @instabilities.delete(sg_id)
304
+ @join_states.delete(sg_id)
305
+ end
154
306
  end
155
307
 
156
308
  # Wraps the logic with a mutex
@@ -194,15 +346,26 @@ module Karafka
194
346
  end
195
347
  end
196
348
 
197
- # @return [Hash] response body status
349
+ # @return [Hash] response body status. Takes a single locked snapshot of all three TTL
350
+ # flags and caches it for the duration of this call so that `super` (which calls
351
+ # `healthy?` to populate the status field) and the merged `errors` hash see the same
352
+ # values. Avoids the previous pattern of taking the lock six times per response, where
353
+ # state could change between acquisitions and leave the status field disagreeing with
354
+ # the errors hash. The base envelope (timestamp, port, process_id, ...) still comes
355
+ # from `super` so any future field added to `BaseListener#status_body` is preserved.
198
356
  def status_body
357
+ @evaluation_snapshot = evaluate_ttl_flags
358
+
199
359
  super.merge!(
200
360
  errors: {
201
- polling_ttl_exceeded: polling_ttl_exceeded?,
202
- consumption_ttl_exceeded: consuming_ttl_exceeded?,
361
+ polling_ttl_exceeded: @evaluation_snapshot[:polling],
362
+ consumption_ttl_exceeded: @evaluation_snapshot[:consuming],
363
+ stability_ttl_exceeded: @evaluation_snapshot[:stability],
203
364
  unrecoverable: @unrecoverable
204
365
  }
205
366
  )
367
+ ensure
368
+ @evaluation_snapshot = nil
206
369
  end
207
370
  end
208
371
  end
@@ -30,7 +30,6 @@
30
30
 
31
31
  module Karafka
32
32
  module Pro
33
- # Pro Admin utilities
34
33
  module Admin
35
34
  class Recovery < Karafka::Admin
36
35
  # Recovery related errors
@@ -159,14 +159,16 @@ module Karafka
159
159
  # )
160
160
  #
161
161
  # @example Read offsets from a specific point in time
162
- # Karafka::Admin::Recovery.read_committed_offsets('sync', last_committed_at: Time.new(2025, 3, 1))
162
+ # Karafka::Admin::Recovery.read_committed_offsets(
163
+ # 'sync', last_committed_at: Time.new(2025, 3, 1)
164
+ # )
163
165
  #
164
166
  # @example Migrate a stuck consumer group to a new name (two-step workflow)
165
167
  # # Step 1: Read committed offsets from the broken group (bypasses coordinator)
166
168
  # offsets = Karafka::Admin::Recovery.read_committed_offsets('sync')
167
169
  # #=> { 'events' => { 0 => 1400, 1 => 1402 }, 'orders' => { 0 => 890 } }
168
170
  #
169
- # # Step 2: Inspect the recovered offsets verify all expected topics and partitions
171
+ # # Step 2: Inspect the recovered offsets - verify all expected topics and partitions
170
172
  # # are present and the offset values look reasonable before committing them
171
173
  #
172
174
  # # Step 3: Write the offsets to the target group using standard Admin APIs
@@ -193,11 +195,11 @@ module Karafka
193
195
  next unless parsed[:group] == group_id
194
196
 
195
197
  if parsed[:offset].nil?
196
- # Tombstone offset was deleted, remove from results
198
+ # Tombstone - offset was deleted, remove from results
197
199
  committed[parsed[:topic]].delete(parsed[:partition])
198
200
  committed.delete(parsed[:topic]) if committed[parsed[:topic]].empty?
199
201
  else
200
- # Last write wins scanning forward means we naturally end up with the most
202
+ # Last write wins - scanning forward means we naturally end up with the most
201
203
  # recent commit per partition
202
204
  committed[parsed[:topic]][parsed[:partition]] = parsed[:offset]
203
205
  end