rdkafka 0.22.2 → 0.29.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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +101 -3
  3. data/README.md +21 -14
  4. data/Rakefile +21 -21
  5. data/bin/verify_kafka_warnings +39 -0
  6. data/dist/{librdkafka-2.8.0.tar.gz → librdkafka-2.14.2.tar.gz} +0 -0
  7. data/docker-compose-ssl.yml +35 -0
  8. data/docker-compose.yml +2 -2
  9. data/ext/Rakefile +27 -27
  10. data/lib/rdkafka/abstract_handle.rb +54 -7
  11. data/lib/rdkafka/admin/acl_binding_result.rb +5 -5
  12. data/lib/rdkafka/admin/config_binding_result.rb +2 -2
  13. data/lib/rdkafka/admin/config_resource_binding_result.rb +1 -0
  14. data/lib/rdkafka/admin/create_acl_handle.rb +10 -6
  15. data/lib/rdkafka/admin/create_acl_report.rb +3 -2
  16. data/lib/rdkafka/admin/create_partitions_handle.rb +8 -7
  17. data/lib/rdkafka/admin/create_partitions_report.rb +1 -0
  18. data/lib/rdkafka/admin/create_topic_handle.rb +8 -7
  19. data/lib/rdkafka/admin/create_topic_report.rb +3 -0
  20. data/lib/rdkafka/admin/delete_acl_handle.rb +9 -8
  21. data/lib/rdkafka/admin/delete_acl_report.rb +5 -3
  22. data/lib/rdkafka/admin/delete_groups_handle.rb +10 -7
  23. data/lib/rdkafka/admin/delete_groups_report.rb +3 -0
  24. data/lib/rdkafka/admin/delete_topic_handle.rb +8 -7
  25. data/lib/rdkafka/admin/delete_topic_report.rb +3 -0
  26. data/lib/rdkafka/admin/describe_acl_handle.rb +9 -8
  27. data/lib/rdkafka/admin/describe_acl_report.rb +5 -3
  28. data/lib/rdkafka/admin/describe_configs_handle.rb +7 -10
  29. data/lib/rdkafka/admin/describe_configs_report.rb +8 -6
  30. data/lib/rdkafka/admin/incremental_alter_configs_handle.rb +7 -10
  31. data/lib/rdkafka/admin/incremental_alter_configs_report.rb +8 -6
  32. data/lib/rdkafka/admin/list_offsets_handle.rb +31 -0
  33. data/lib/rdkafka/admin/list_offsets_report.rb +51 -0
  34. data/lib/rdkafka/admin.rb +357 -159
  35. data/lib/rdkafka/bindings.rb +205 -111
  36. data/lib/rdkafka/callbacks/base_handler.rb +62 -0
  37. data/lib/rdkafka/callbacks/create_acl_handler.rb +37 -0
  38. data/lib/rdkafka/callbacks/create_partitions_handler.rb +37 -0
  39. data/lib/rdkafka/callbacks/create_topic_handler.rb +37 -0
  40. data/lib/rdkafka/callbacks/delete_acl_handler.rb +42 -0
  41. data/lib/rdkafka/callbacks/delete_groups_handler.rb +37 -0
  42. data/lib/rdkafka/callbacks/delete_topic_handler.rb +37 -0
  43. data/lib/rdkafka/callbacks/describe_acl_handler.rb +35 -0
  44. data/lib/rdkafka/callbacks/describe_configs_handler.rb +42 -0
  45. data/lib/rdkafka/callbacks/incremental_alter_configs_handler.rb +42 -0
  46. data/lib/rdkafka/callbacks/list_offsets_handler.rb +42 -0
  47. data/lib/rdkafka/callbacks.rb +125 -216
  48. data/lib/rdkafka/config.rb +126 -66
  49. data/lib/rdkafka/consumer/headers.rb +22 -7
  50. data/lib/rdkafka/consumer/message.rb +12 -11
  51. data/lib/rdkafka/consumer/partition.rb +15 -4
  52. data/lib/rdkafka/consumer/topic_partition_list.rb +64 -38
  53. data/lib/rdkafka/consumer.rb +513 -59
  54. data/lib/rdkafka/defaults.rb +125 -0
  55. data/lib/rdkafka/error.rb +40 -14
  56. data/lib/rdkafka/helpers/metadata.rb +29 -0
  57. data/lib/rdkafka/helpers/oauth.rb +45 -13
  58. data/lib/rdkafka/helpers/time.rb +5 -0
  59. data/lib/rdkafka/metadata.rb +128 -37
  60. data/lib/rdkafka/native_kafka.rb +91 -8
  61. data/lib/rdkafka/producer/delivery_handle.rb +6 -6
  62. data/lib/rdkafka/producer/delivery_report.rb +10 -6
  63. data/lib/rdkafka/producer/partitions_count_cache.rb +51 -55
  64. data/lib/rdkafka/producer.rb +193 -95
  65. data/lib/rdkafka/version.rb +6 -3
  66. data/lib/rdkafka.rb +15 -0
  67. data/package-lock.json +331 -0
  68. data/package.json +9 -0
  69. data/rdkafka.gemspec +58 -36
  70. data/renovate.json +39 -23
  71. metadata +39 -125
  72. data/.github/CODEOWNERS +0 -3
  73. data/.github/FUNDING.yml +0 -1
  74. data/.github/workflows/ci_linux_x86_64_gnu.yml +0 -271
  75. data/.github/workflows/ci_linux_x86_64_musl.yml +0 -194
  76. data/.github/workflows/ci_macos_arm64.yml +0 -284
  77. data/.github/workflows/push_linux_x86_64_gnu.yml +0 -65
  78. data/.github/workflows/push_linux_x86_64_musl.yml +0 -79
  79. data/.github/workflows/push_macos_arm64.yml +0 -54
  80. data/.github/workflows/push_ruby.yml +0 -37
  81. data/.github/workflows/verify-action-pins.yml +0 -16
  82. data/.gitignore +0 -14
  83. data/.rspec +0 -2
  84. data/.ruby-gemset +0 -1
  85. data/.ruby-version +0 -1
  86. data/.yardopts +0 -2
  87. data/Gemfile +0 -5
  88. data/ext/README.md +0 -19
  89. data/ext/build_common.sh +0 -361
  90. data/ext/build_linux_x86_64_gnu.sh +0 -306
  91. data/ext/build_linux_x86_64_musl.sh +0 -763
  92. data/ext/build_macos_arm64.sh +0 -550
  93. data/spec/rdkafka/abstract_handle_spec.rb +0 -117
  94. data/spec/rdkafka/admin/create_acl_handle_spec.rb +0 -56
  95. data/spec/rdkafka/admin/create_acl_report_spec.rb +0 -18
  96. data/spec/rdkafka/admin/create_topic_handle_spec.rb +0 -52
  97. data/spec/rdkafka/admin/create_topic_report_spec.rb +0 -16
  98. data/spec/rdkafka/admin/delete_acl_handle_spec.rb +0 -85
  99. data/spec/rdkafka/admin/delete_acl_report_spec.rb +0 -72
  100. data/spec/rdkafka/admin/delete_topic_handle_spec.rb +0 -52
  101. data/spec/rdkafka/admin/delete_topic_report_spec.rb +0 -16
  102. data/spec/rdkafka/admin/describe_acl_handle_spec.rb +0 -85
  103. data/spec/rdkafka/admin/describe_acl_report_spec.rb +0 -73
  104. data/spec/rdkafka/admin_spec.rb +0 -971
  105. data/spec/rdkafka/bindings_spec.rb +0 -199
  106. data/spec/rdkafka/callbacks_spec.rb +0 -20
  107. data/spec/rdkafka/config_spec.rb +0 -258
  108. data/spec/rdkafka/consumer/headers_spec.rb +0 -73
  109. data/spec/rdkafka/consumer/message_spec.rb +0 -139
  110. data/spec/rdkafka/consumer/partition_spec.rb +0 -57
  111. data/spec/rdkafka/consumer/topic_partition_list_spec.rb +0 -248
  112. data/spec/rdkafka/consumer_spec.rb +0 -1274
  113. data/spec/rdkafka/error_spec.rb +0 -89
  114. data/spec/rdkafka/metadata_spec.rb +0 -79
  115. data/spec/rdkafka/native_kafka_spec.rb +0 -130
  116. data/spec/rdkafka/producer/delivery_handle_spec.rb +0 -45
  117. data/spec/rdkafka/producer/delivery_report_spec.rb +0 -25
  118. data/spec/rdkafka/producer/partitions_count_cache_spec.rb +0 -359
  119. data/spec/rdkafka/producer_spec.rb +0 -1345
  120. data/spec/spec_helper.rb +0 -195
@@ -4,7 +4,13 @@ module Rdkafka
4
4
  # @private
5
5
  # A wrapper around a native kafka that polls and cleanly exits
6
6
  class NativeKafka
7
- def initialize(inner, run_polling_thread:, opaque:, auto_start: true, timeout_ms: 100)
7
+ # Creates a new NativeKafka wrapper
8
+ # @param inner [FFI::Pointer] pointer to the native Kafka handle
9
+ # @param run_polling_thread [Boolean] whether to run a background polling thread
10
+ # @param opaque [Rdkafka::Opaque] opaque object for callback context
11
+ # @param auto_start [Boolean] whether to start the polling thread automatically
12
+ # @param timeout_ms [Integer] poll timeout in milliseconds
13
+ def initialize(inner, run_polling_thread:, opaque:, auto_start: true, timeout_ms: Defaults::NATIVE_KAFKA_POLL_TIMEOUT_MS)
8
14
  @inner = inner
9
15
  @opaque = opaque
10
16
  # Lock around external access
@@ -37,6 +43,8 @@ module Rdkafka
37
43
  @closing = false
38
44
  end
39
45
 
46
+ # Starts the polling thread if configured
47
+ # @return [nil]
40
48
  def start
41
49
  synchronize do
42
50
  return if @started
@@ -47,8 +55,7 @@ module Rdkafka
47
55
  Rdkafka::Bindings.rd_kafka_poll(@inner, 0)
48
56
 
49
57
  if @run_polling_thread
50
- # Start thread to poll client for delivery callbacks,
51
- # not used in consumer.
58
+ # Start thread to poll client for delivery callbacks, not used in consumer.
52
59
  @polling_thread = Thread.new do
53
60
  loop do
54
61
  @poll_mutex.synchronize do
@@ -62,13 +69,17 @@ module Rdkafka
62
69
  end
63
70
  end
64
71
 
65
- @polling_thread.name = "rdkafka.native_kafka##{Rdkafka::Bindings.rd_kafka_name(@inner).gsub('rdkafka', '')}"
72
+ @polling_thread.name = "rdkafka.native_kafka##{Rdkafka::Bindings.rd_kafka_name(@inner).gsub("rdkafka", "")}"
66
73
  @polling_thread.abort_on_exception = true
67
74
  @polling_thread[:closing] = false
68
75
  end
69
76
  end
70
77
  end
71
78
 
79
+ # Executes a block with the inner native Kafka handle
80
+ # @yield [FFI::Pointer] the inner native Kafka handle
81
+ # @return [Object] the result of the block
82
+ # @raise [ClosedInnerError] when the inner handle is nil
72
83
  def with_inner
73
84
  if @access_mutex.owned?
74
85
  @operations_in_progress += 1
@@ -81,27 +92,100 @@ module Rdkafka
81
92
  @decrement_mutex.synchronize { @operations_in_progress -= 1 }
82
93
  end
83
94
 
95
+ # Executes a block while holding exclusive access to the native Kafka handle
96
+ # @param block [Proc] block to execute with the native handle
97
+ # @yield [FFI::Pointer] the inner native Kafka handle
98
+ # @return [Object] the result of the block
84
99
  def synchronize(&block)
85
100
  @access_mutex.synchronize do
86
101
  # Wait for any commands using the inner to finish
87
102
  # This can take a while on blocking operations like polling but is essential not to proceed
88
103
  # with certain types of operations like resources destruction as it can cause the process
89
104
  # to hang or crash
90
- sleep(0.01) until @operations_in_progress.zero?
105
+ sleep(Defaults::NATIVE_KAFKA_SYNCHRONIZE_SLEEP_INTERVAL_MS / 1000.0) until @operations_in_progress.zero?
91
106
 
92
107
  with_inner(&block)
93
108
  end
94
109
  end
95
110
 
111
+ # Returns a finalizer proc for closing this native Kafka handle
112
+ # @return [Proc] finalizer proc
96
113
  def finalizer
97
114
  ->(_) { close }
98
115
  end
99
116
 
117
+ # Returns whether this native Kafka handle is closed or closing
118
+ # @return [Boolean] true if closed or closing
100
119
  def closed?
101
120
  @closing || @inner.nil?
102
121
  end
103
122
 
104
- def close(object_id=nil)
123
+ # Enable IO event notifications on the main queue
124
+ # Librdkafka will write to your FD when the queue transitions from empty to non-empty
125
+ #
126
+ # @note This method is incompatible with background polling threads.
127
+ # If background polling is enabled, use manual polling instead (e.g., consumer.poll)
128
+ #
129
+ # @param fd [Integer] your file descriptor (from IO.pipe or eventfd)
130
+ # @param payload [String] data to write to fd when queue has data (default: "\x01")
131
+ # @return [nil]
132
+ # @raise [ClosedInnerError] when the handle is closed
133
+ # @raise [RuntimeError] when background polling thread is active
134
+ #
135
+ # @example
136
+ # # Create your own signaling FD
137
+ # signal_r, signal_w = IO.pipe
138
+ # native_kafka.enable_main_queue_io_events(signal_w.fileno)
139
+ #
140
+ # # Monitor it with select
141
+ # readable, = IO.select([signal_r], nil, nil, timeout)
142
+ # if readable
143
+ # consumer.poll(0) # Get messages
144
+ # end
145
+ def enable_main_queue_io_events(fd, payload = "\x01")
146
+ if @run_polling_thread
147
+ raise "Cannot enable IO events while background polling thread is active. " \
148
+ "Either disable background polling by setting run_polling_thread: false, " \
149
+ "or use manual polling with consumer.poll() instead of the FD API."
150
+ end
151
+
152
+ with_inner do |inner|
153
+ queue_ptr = Bindings.rd_kafka_queue_get_main(inner)
154
+ Bindings.rd_kafka_queue_io_event_enable(queue_ptr, fd, payload, payload.bytesize)
155
+ Bindings.rd_kafka_queue_destroy(queue_ptr)
156
+ end
157
+ end
158
+
159
+ # Enable IO event notifications on the background queue
160
+ # Librdkafka will write to your FD when the background queue transitions from empty to non-empty
161
+ #
162
+ # @note This method is incompatible with background polling threads.
163
+ # If background polling is enabled, use manual polling instead (e.g., consumer.poll)
164
+ #
165
+ # @param fd [Integer] your file descriptor (from IO.pipe or eventfd)
166
+ # @param payload [String] data to write to fd when queue has data (default: "\x01")
167
+ # @return [nil]
168
+ # @raise [ClosedInnerError] when the handle is closed
169
+ # @raise [RuntimeError] when background polling thread is active
170
+ def enable_background_queue_io_events(fd, payload = "\x01")
171
+ if @run_polling_thread
172
+ raise "Cannot enable IO events while background polling thread is active. " \
173
+ "Either disable background polling by setting run_polling_thread: false, " \
174
+ "or use manual polling with consumer.poll() instead of the FD API."
175
+ end
176
+
177
+ with_inner do |inner|
178
+ queue_ptr = Bindings.rd_kafka_queue_get_background(inner)
179
+ Bindings.rd_kafka_queue_io_event_enable(queue_ptr, fd, payload, payload.bytesize)
180
+ Bindings.rd_kafka_queue_destroy(queue_ptr)
181
+ end
182
+ end
183
+
184
+ # Closes the native Kafka handle and cleans up resources
185
+ # @param object_id [Integer, nil] optional object ID (unused, for finalizer compatibility)
186
+ # @yield optional block to execute before destroying the handle
187
+ # @return [nil]
188
+ def close(object_id = nil)
105
189
  return if closed?
106
190
 
107
191
  synchronize do
@@ -112,8 +196,7 @@ module Rdkafka
112
196
  # Indicate to polling thread that we're closing
113
197
  @polling_thread[:closing] = true
114
198
 
115
- # Wait for the polling thread to finish up,
116
- # this can be aborted in practice if this
199
+ # Wait for the polling thread to finish up, this can be aborted in practice if this
117
200
  # code runs from a finalizer.
118
201
  @polling_thread.join
119
202
  end
@@ -6,16 +6,16 @@ module Rdkafka
6
6
  # producing a message.
7
7
  class DeliveryHandle < Rdkafka::AbstractHandle
8
8
  layout :pending, :bool,
9
- :response, :int,
10
- :partition, :int,
11
- :offset, :int64,
12
- :topic_name, :pointer
9
+ :response, :int,
10
+ :partition, :int,
11
+ :offset, :int64
13
12
 
14
13
  # @return [Object, nil] label set during message production or nil by default
15
14
  attr_accessor :label
16
15
 
17
16
  # @return [String] topic where we are trying to send the message
18
- # We use this instead of reading from `topic_name` pointer to save on memory allocations
17
+ # Set in `#produce`, where the topic is known upfront. Keeping it as a Ruby attribute
18
+ # spares a per-message native string copy in the delivery callback.
19
19
  attr_accessor :topic
20
20
 
21
21
  # @return [String] the name of the operation (e.g. "delivery")
@@ -31,7 +31,7 @@ module Rdkafka
31
31
  # For part of errors, we will not get a topic name reference and in cases like this
32
32
  # we should not return it
33
33
  topic,
34
- self[:response] != 0 ? RdkafkaError.new(self[:response]) : nil,
34
+ (self[:response] != 0) ? RdkafkaError.new(self[:response]) : nil,
35
35
  label
36
36
  )
37
37
  end
@@ -12,8 +12,8 @@ module Rdkafka
12
12
  # @return [Integer]
13
13
  attr_reader :offset
14
14
 
15
- # The name of the topic this message was produced to or nil in case of reports with errors
16
- # where topic was not reached.
15
+ # The name of the topic this message was produced to or nil in case delivery failed and we
16
+ # we not able to get the topic reference
17
17
  #
18
18
  # @return [String, nil]
19
19
  attr_reader :topic_name
@@ -30,10 +30,14 @@ module Rdkafka
30
30
  # is present in both places
31
31
  #
32
32
  # We do not remove the original `#topic_name` because of backwards compatibility
33
- alias topic topic_name
34
-
35
- private
36
-
33
+ alias_method :topic, :topic_name
34
+
35
+ # @private
36
+ # @param partition [Integer] partition number
37
+ # @param offset [Integer] message offset
38
+ # @param topic_name [String, nil] topic name
39
+ # @param error [Integer, nil] error code if any
40
+ # @param label [Object, nil] user-defined label
37
41
  def initialize(partition, offset, topic_name = nil, error = nil, label = nil)
38
42
  @partition = partition
39
43
  @offset = offset
@@ -17,9 +17,9 @@ module Rdkafka
17
17
  #
18
18
  # 2. Edge case handling
19
19
  # If a user configures `statistics.interval.ms` much higher than the default cache TTL
20
- # (30 seconds), the cache will still function correctly. When statistics updates don't
21
- # occur frequently enough, the cache entries will expire naturally, triggering a
22
- # blocking refresh when needed.
20
+ # ({Defaults::PARTITIONS_COUNT_CACHE_TTL_MS}ms), the cache will still function correctly.
21
+ # When statistics updates don't occur frequently enough, the cache entries will expire
22
+ # naturally, triggering a blocking refresh when needed.
23
23
  #
24
24
  # 3. User configuration awareness
25
25
  # The cache respects user-defined settings. If `topic.metadata.refresh.interval.ms` is
@@ -37,31 +37,40 @@ module Rdkafka
37
37
  # contention in multi-threaded environments while ensuring data consistency.
38
38
  #
39
39
  # 6. Topic recreation handling
40
- # If a topic is deleted and recreated with fewer partitions, the cache will continue to
41
- # report the higher count until either the TTL expires or the process is restarted. This
42
- # design choice simplifies the implementation while relying on librdkafka's error handling
43
- # for edge cases. In production environments, topic recreation with different partition
44
- # counts is typically accompanied by application restarts to handle structural changes.
45
- # This also aligns with the previous cache implementation.
40
+ # If a topic is deleted and recreated with fewer partitions, the cache keeps reporting the
41
+ # higher count only until the entry's TTL expires. The first refresh after expiry performs
42
+ # an authoritative metadata read and adopts the lower count. Within the TTL window a lower
43
+ # value is still ignored, so a transient or racy lower read cannot clobber a correct higher
44
+ # count.
46
45
  class PartitionsCountCache
47
46
  include Helpers::Time
48
47
 
49
- # Default time-to-live for cached partition counts in seconds
50
- #
51
- # @note This default was chosen to balance freshness of metadata with performance
52
- # optimization. Most Kafka cluster topology changes are planned operations, making 30
53
- # seconds a reasonable compromise.
54
- DEFAULT_TTL = 30
55
-
56
48
  # Creates a new partition count cache
57
49
  #
58
- # @param ttl [Integer] Time-to-live in seconds for cached values
59
- def initialize(ttl = DEFAULT_TTL)
50
+ # @param ttl [Integer, nil] DEPRECATED: Use ttl_ms instead.
51
+ # Time-to-live in seconds for cached values. Will be removed in v1.0.0.
52
+ # @param ttl_ms [Integer, nil] Time-to-live in milliseconds for cached values.
53
+ # Defaults to {Defaults::PARTITIONS_COUNT_CACHE_TTL_MS}.
54
+ def initialize(ttl = :not_provided, ttl_ms: :not_provided)
60
55
  @counts = {}
61
56
  @mutex_hash = {}
62
57
  # Used only for @mutex_hash access to ensure thread-safety when creating new mutexes
63
58
  @mutex_for_hash = Mutex.new
64
- @ttl = ttl
59
+
60
+ # Determine which TTL value to use
61
+ if ttl != :not_provided && ttl_ms != :not_provided
62
+ warn "DEPRECATION WARNING: Both ttl and ttl_ms were provided to PartitionsCountCache. " \
63
+ "Using ttl_ms. The ttl parameter is deprecated and will be removed in v1.0.0."
64
+ @ttl_ms = ttl_ms
65
+ elsif ttl != :not_provided
66
+ warn "DEPRECATION WARNING: ttl (seconds) parameter for PartitionsCountCache is deprecated. " \
67
+ "Use ttl_ms (milliseconds) instead. This parameter will be removed in v1.0.0."
68
+ @ttl_ms = (ttl * 1000).to_i
69
+ elsif ttl_ms == :not_provided
70
+ @ttl_ms = Defaults::PARTITIONS_COUNT_CACHE_TTL_MS
71
+ else
72
+ @ttl_ms = ttl_ms
73
+ end
65
74
  end
66
75
 
67
76
  # Reads partition count for a topic with automatic refresh when expired
@@ -81,28 +90,14 @@ module Rdkafka
81
90
  current_info = @counts[topic]
82
91
 
83
92
  if current_info.nil? || expired?(current_info[0])
93
+ # The cached entry is missing or expired, so the block performs an authoritative metadata
94
+ # read. We hand it to `set`, which adopts a higher count always and a lower count once the
95
+ # entry has expired (e.g. the topic was recreated with fewer partitions). We then return
96
+ # whatever `set` settled on so a concurrent refresh that wrote a higher value still wins.
84
97
  new_count = yield
98
+ set(topic, new_count)
85
99
 
86
- if current_info.nil?
87
- # No existing data, create a new entry with mutex
88
- set(topic, new_count)
89
-
90
- return new_count
91
- else
92
- current_count = current_info[1]
93
-
94
- if new_count > current_count
95
- # Higher value needs mutex to update both timestamp and count
96
- set(topic, new_count)
97
-
98
- return new_count
99
- else
100
- # Same or lower value, just update timestamp without mutex
101
- refresh_timestamp(topic)
102
-
103
- return current_count
104
- end
105
- end
100
+ return @counts[topic][1]
106
101
  end
107
102
 
108
103
  current_info[1]
@@ -123,8 +118,11 @@ module Rdkafka
123
118
  # First check outside mutex to avoid unnecessary locking
124
119
  current_info = @counts[topic]
125
120
 
126
- # For lower values, we don't update count but might need to refresh timestamp
127
- if current_info && new_count < current_info[1]
121
+ # Within the TTL window a lower value is treated as a stale/racy read and ignored, since
122
+ # partition counts only grow during normal operation. Once the entry has expired a lower
123
+ # value is an authoritative refresh (e.g. the topic was recreated with fewer partitions),
124
+ # so we fall through and adopt it below.
125
+ if current_info && new_count < current_info[1] && !expired?(current_info[0])
128
126
  refresh_timestamp(topic)
129
127
 
130
128
  return
@@ -137,18 +135,16 @@ module Rdkafka
137
135
 
138
136
  if current_info.nil?
139
137
  # Create new entry
140
- @counts[topic] = [monotonic_now, new_count]
138
+ @counts[topic] = [monotonic_now_ms, new_count]
139
+ elsif new_count > current_info[1] || expired?(current_info[0])
140
+ # A higher count always wins; a lower count is accepted only when the existing entry
141
+ # has expired, so a concurrent fresh higher value (which reset the timestamp) is never
142
+ # clobbered by a stale lower one.
143
+ current_info[0] = monotonic_now_ms
144
+ current_info[1] = new_count
141
145
  else
142
- current_count = current_info[1]
143
-
144
- if new_count > current_count
145
- # Update to higher count value
146
- current_info[0] = monotonic_now
147
- current_info[1] = new_count
148
- else
149
- # Same or lower count, update timestamp only
150
- current_info[0] = monotonic_now
151
- end
146
+ # Same or lower count within the TTL window: refresh the timestamp only
147
+ current_info[0] = monotonic_now_ms
152
148
  end
153
149
  end
154
150
  end
@@ -201,15 +197,15 @@ module Rdkafka
201
197
  return unless current_info
202
198
 
203
199
  # Update the timestamp in-place
204
- current_info[0] = monotonic_now
200
+ current_info[0] = monotonic_now_ms
205
201
  end
206
202
 
207
203
  # Check if a timestamp has expired based on the TTL
208
204
  #
209
- # @param timestamp [Float] Monotonic timestamp to check
205
+ # @param timestamp [Integer] Monotonic timestamp in milliseconds to check
210
206
  # @return [Boolean] true if expired, false otherwise
211
207
  def expired?(timestamp)
212
- monotonic_now - timestamp > @ttl
208
+ monotonic_now_ms - timestamp > @ttl_ms
213
209
  end
214
210
  end
215
211
  end