karafka-rdkafka 0.27.2-aarch64-linux-gnu → 0.28.1-aarch64-linux-gnu

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/README.md +4 -3
  4. data/docker-compose-ssl.yml +1 -1
  5. data/docker-compose.yml +1 -1
  6. data/ext/librdkafka.so +0 -0
  7. data/karafka-rdkafka.gemspec +1 -0
  8. data/lib/rdkafka/abstract_handle.rb +37 -4
  9. data/lib/rdkafka/admin/config_binding_result.rb +1 -1
  10. data/lib/rdkafka/admin/create_acl_handle.rb +2 -8
  11. data/lib/rdkafka/admin/create_acl_report.rb +2 -1
  12. data/lib/rdkafka/admin/create_partitions_handle.rb +1 -20
  13. data/lib/rdkafka/admin/create_topic_handle.rb +1 -20
  14. data/lib/rdkafka/admin/delete_acl_handle.rb +2 -10
  15. data/lib/rdkafka/admin/delete_groups_handle.rb +3 -11
  16. data/lib/rdkafka/admin/delete_records_handle.rb +31 -0
  17. data/lib/rdkafka/admin/delete_records_report.rb +25 -0
  18. data/lib/rdkafka/admin/delete_topic_handle.rb +1 -20
  19. data/lib/rdkafka/admin/describe_acl_handle.rb +2 -10
  20. data/lib/rdkafka/admin/describe_acl_report.rb +2 -1
  21. data/lib/rdkafka/admin/describe_configs_handle.rb +2 -13
  22. data/lib/rdkafka/admin/describe_configs_report.rb +5 -6
  23. data/lib/rdkafka/admin/incremental_alter_configs_handle.rb +2 -13
  24. data/lib/rdkafka/admin/incremental_alter_configs_report.rb +5 -6
  25. data/lib/rdkafka/admin/list_consumer_groups_handle.rb +31 -0
  26. data/lib/rdkafka/admin/list_consumer_groups_report.rb +83 -0
  27. data/lib/rdkafka/admin/list_offsets_handle.rb +2 -13
  28. data/lib/rdkafka/admin/list_offsets_report.rb +5 -2
  29. data/lib/rdkafka/admin.rb +233 -145
  30. data/lib/rdkafka/bindings.rb +63 -16
  31. data/lib/rdkafka/callbacks/base_handler.rb +62 -0
  32. data/lib/rdkafka/callbacks/create_acl_handler.rb +37 -0
  33. data/lib/rdkafka/callbacks/create_partitions_handler.rb +37 -0
  34. data/lib/rdkafka/callbacks/create_topic_handler.rb +37 -0
  35. data/lib/rdkafka/callbacks/delete_acl_handler.rb +42 -0
  36. data/lib/rdkafka/callbacks/delete_groups_handler.rb +37 -0
  37. data/lib/rdkafka/callbacks/delete_records_handler.rb +38 -0
  38. data/lib/rdkafka/callbacks/delete_topic_handler.rb +37 -0
  39. data/lib/rdkafka/callbacks/describe_acl_handler.rb +35 -0
  40. data/lib/rdkafka/callbacks/describe_configs_handler.rb +42 -0
  41. data/lib/rdkafka/callbacks/incremental_alter_configs_handler.rb +42 -0
  42. data/lib/rdkafka/callbacks/list_consumer_groups_handler.rb +39 -0
  43. data/lib/rdkafka/callbacks/list_offsets_handler.rb +42 -0
  44. data/lib/rdkafka/callbacks.rb +60 -246
  45. data/lib/rdkafka/config.rb +69 -50
  46. data/lib/rdkafka/consumer/headers.rb +19 -5
  47. data/lib/rdkafka/consumer/topic_partition_list.rb +57 -42
  48. data/lib/rdkafka/consumer.rb +190 -48
  49. data/lib/rdkafka/defaults.rb +32 -3
  50. data/lib/rdkafka/error.rb +14 -5
  51. data/lib/rdkafka/helpers/list_offsets.rb +127 -0
  52. data/lib/rdkafka/helpers/metadata.rb +29 -0
  53. data/lib/rdkafka/helpers/oauth.rb +10 -5
  54. data/lib/rdkafka/metadata.rb +84 -19
  55. data/lib/rdkafka/native_kafka.rb +20 -7
  56. data/lib/rdkafka/producer/delivery_handle.rb +4 -5
  57. data/lib/rdkafka/producer/partitions_count_cache.rb +24 -38
  58. data/lib/rdkafka/producer/testing.rb +2 -1
  59. data/lib/rdkafka/producer.rb +100 -71
  60. data/lib/rdkafka/version.rb +3 -3
  61. data/lib/rdkafka.rb +19 -0
  62. data/package-lock.json +6 -6
  63. data/renovate.json +11 -1
  64. metadata +21 -5
  65. data/Gemfile +0 -13
  66. data/Gemfile.lint +0 -14
  67. data/Gemfile.lint.lock +0 -123
data/lib/rdkafka/error.rb CHANGED
@@ -35,8 +35,9 @@ module Rdkafka
35
35
  # @param response_ptr [FFI::Pointer] Pointer to rd_kafka_error_t
36
36
  # @param message_prefix [String, nil] Optional prefix for the error message
37
37
  # @param broker_message [String, nil] Optional broker error message
38
+ # @param instance_name [String, nil] Optional name of the rdkafka instance
38
39
  # @return [RdkafkaError, false] Error instance or false if no error
39
- def build_from_c(response_ptr, message_prefix = nil, broker_message: nil)
40
+ def build_from_c(response_ptr, message_prefix = nil, broker_message: nil, instance_name: nil)
40
41
  code = Rdkafka::Bindings.rd_kafka_error_code(response_ptr)
41
42
 
42
43
  return false if code == Bindings::RD_KAFKA_RESP_ERR_NO_ERROR
@@ -54,7 +55,8 @@ module Rdkafka
54
55
  broker_message: message,
55
56
  fatal: fatal,
56
57
  retryable: retryable,
57
- abortable: abortable
58
+ abortable: abortable,
59
+ instance_name: instance_name
58
60
  )
59
61
  end
60
62
 
@@ -92,10 +94,16 @@ module Rdkafka
92
94
  response_ptr_or_code[:err],
93
95
  message_prefix,
94
96
  broker_message: broker_message,
95
- details: details
97
+ details: details,
98
+ instance_name: instance_name
96
99
  )
97
100
  else
98
- build_from_c(response_ptr_or_code, message_prefix)
101
+ build_from_c(
102
+ response_ptr_or_code,
103
+ message_prefix,
104
+ broker_message: broker_message,
105
+ instance_name: instance_name
106
+ )
99
107
  end
100
108
  end
101
109
 
@@ -131,7 +139,8 @@ module Rdkafka
131
139
  # Calls rd_kafka_fatal_error() to get the actual underlying error code and description.
132
140
  #
133
141
  # @param client_ptr [FFI::Pointer] Pointer to rd_kafka_t client
134
- # @param fallback_error_code [Integer] Error code to use if no fatal error found (default: -150)
142
+ # @param fallback_error_code [Integer] Error code to use if no fatal error found
143
+ # (default: -150)
135
144
  # @param fallback_message [String, nil] Message to use if no fatal error found
136
145
  # @param instance_name [String, nil] Optional name of the rdkafka instance
137
146
  # @return [RdkafkaError] Error object with fatal flag set to true
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ module Helpers
5
+ # Shared `#list_offsets` implementation for Admin and Consumer.
6
+ #
7
+ # The librdkafka Admin API is handle-agnostic: `rd_kafka_ListOffsets()` can be issued on any
8
+ # `rd_kafka_t` instance regardless of its type, so this Ruby-level implementation is identical
9
+ # for admin and consumer clients. Includers must provide a private `#closed_check(method)`
10
+ # that raises their own `Closed*Error`, and must have the background event callback
11
+ # registered on their native config (see `Config#admin` and `Config#consumer`) so the result
12
+ # event is dispatched back to the returned handle.
13
+ module ListOffsets
14
+ # Queries partition offsets by specification (earliest, latest, max_timestamp, or by
15
+ # timestamp) without requiring a consumer group.
16
+ #
17
+ # The query is batched: all requested partitions are carried in one `ListOffsets` request
18
+ # that librdkafka fans out to the involved partition leaders internally and concurrently.
19
+ #
20
+ # @param topic_partition_offsets [Hash{String => Array<Hash>}] hash mapping topic names to
21
+ # arrays of partition offset specifications. Each specification is a hash with:
22
+ # - `:partition` [Integer] partition number
23
+ # - `:offset` [Symbol, Integer] offset specification - `:earliest`, `:latest`,
24
+ # `:max_timestamp`, or an integer timestamp in milliseconds
25
+ # @param isolation_level [Integer, nil] optional isolation level:
26
+ # - `RD_KAFKA_ISOLATION_LEVEL_READ_UNCOMMITTED` (0) - default
27
+ # - `RD_KAFKA_ISOLATION_LEVEL_READ_COMMITTED` (1)
28
+ #
29
+ # @return [Admin::ListOffsetsHandle] handle that can be used to wait for the result
30
+ #
31
+ # @raise [ClosedAdminError, ClosedConsumerError] when the client is closed
32
+ # @raise [ConfigError] when the background queue is unavailable
33
+ #
34
+ # @example Query earliest and latest offsets
35
+ # handle = client.list_offsets(
36
+ # { "my_topic" => [
37
+ # { partition: 0, offset: :earliest },
38
+ # { partition: 1, offset: :latest }
39
+ # ] }
40
+ # )
41
+ # report = handle.wait(max_wait_timeout_ms: 15_000)
42
+ # report.offsets
43
+ # # => [{ topic: "my_topic", partition: 0, offset: 0, ... }, ...]
44
+ def list_offsets(topic_partition_offsets, isolation_level: nil)
45
+ closed_check(__method__)
46
+
47
+ # Parse and validate every offset spec before allocating the native list, so a missing key
48
+ # or an unknown offset specification raises with nothing to clean up. Previously the
49
+ # ArgumentError (or KeyError) was raised after `rd_kafka_topic_partition_list_new`, leaking
50
+ # the native list.
51
+ parsed = topic_partition_offsets.flat_map do |topic, partitions|
52
+ partitions.map do |spec|
53
+ offset = spec.fetch(:offset)
54
+
55
+ native_offset = case offset
56
+ when :earliest then Rdkafka::Bindings::RD_KAFKA_OFFSET_SPEC_EARLIEST
57
+ when :latest then Rdkafka::Bindings::RD_KAFKA_OFFSET_SPEC_LATEST
58
+ when :max_timestamp then Rdkafka::Bindings::RD_KAFKA_OFFSET_SPEC_MAX_TIMESTAMP
59
+ when Integer then offset
60
+ else
61
+ raise ArgumentError, "Unknown offset specification: #{offset.inspect}"
62
+ end
63
+
64
+ [topic, spec.fetch(:partition), native_offset]
65
+ end
66
+ end
67
+
68
+ # Build native topic partition list
69
+ tpl = Rdkafka::Bindings.rd_kafka_topic_partition_list_new(parsed.size)
70
+
71
+ parsed.each do |topic, partition, native_offset|
72
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_add(tpl, topic, partition)
73
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_set_offset(tpl, topic, partition, native_offset)
74
+ end
75
+
76
+ # Get a pointer to the queue that our request will be enqueued on
77
+ queue_ptr = @native_kafka.with_inner do |inner|
78
+ Rdkafka::Bindings.rd_kafka_queue_get_background(inner)
79
+ end
80
+
81
+ if queue_ptr.null?
82
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
83
+ raise Rdkafka::Config::ConfigError.new("rd_kafka_queue_get_background was NULL")
84
+ end
85
+
86
+ # Create and register the handle we will return to the caller
87
+ handle = Admin::ListOffsetsHandle.new
88
+ handle[:pending] = true
89
+ handle[:response] = Rdkafka::Bindings::RD_KAFKA_PARTITION_UA
90
+
91
+ admin_options_ptr = @native_kafka.with_inner do |inner|
92
+ Rdkafka::Bindings.rd_kafka_AdminOptions_new(
93
+ inner,
94
+ Rdkafka::Bindings::RD_KAFKA_ADMIN_OP_LISTOFFSETS
95
+ )
96
+ end
97
+
98
+ if isolation_level
99
+ Rdkafka::Bindings.rd_kafka_AdminOptions_set_isolation_level(admin_options_ptr, isolation_level)
100
+ end
101
+
102
+ Admin::ListOffsetsHandle.register(handle)
103
+ Rdkafka::Bindings.rd_kafka_AdminOptions_set_opaque(admin_options_ptr, handle.to_ptr)
104
+
105
+ begin
106
+ @native_kafka.with_inner do |inner|
107
+ Rdkafka::Bindings.rd_kafka_ListOffsets(
108
+ inner,
109
+ tpl,
110
+ admin_options_ptr,
111
+ queue_ptr
112
+ )
113
+ end
114
+ rescue Exception
115
+ Admin::ListOffsetsHandle.remove(handle.to_ptr.address)
116
+ raise
117
+ ensure
118
+ Rdkafka::Bindings.rd_kafka_AdminOptions_destroy(admin_options_ptr)
119
+ Rdkafka::Bindings.rd_kafka_queue_destroy(queue_ptr)
120
+ Rdkafka::Bindings.rd_kafka_topic_partition_list_destroy(tpl)
121
+ end
122
+
123
+ handle
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rdkafka
4
+ module Helpers
5
+ # Shared `#metadata` implementation for Admin, Consumer and Producer.
6
+ #
7
+ # `rd_kafka_metadata()` is handle-agnostic in librdkafka - it works on any `rd_kafka_t`
8
+ # (consumer, producer or admin) - so this Ruby-level implementation is identical across all
9
+ # three client types. Includers must provide a private `#closed_check(method)` that raises
10
+ # their own `Closed*Error`.
11
+ module Metadata
12
+ # Performs the metadata request using this client
13
+ #
14
+ # @param topic_name [String, nil] metadata about particular topic or all if nil
15
+ # @param timeout_ms [Integer] metadata request timeout
16
+ # @return [Rdkafka::Metadata] requested metadata
17
+ def metadata(topic_name = nil, timeout_ms = Defaults::METADATA_TIMEOUT_MS)
18
+ closed_check(__method__)
19
+
20
+ @native_kafka.with_inner do |inner|
21
+ # Must stay fully qualified: this module is itself named `Metadata`, so a bare
22
+ # `Metadata.new` here would resolve to `Rdkafka::Helpers::Metadata` (no `.new`) instead
23
+ # of this class.
24
+ Rdkafka::Metadata.new(inner, topic_name, timeout_ms)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -4,10 +4,14 @@ module Rdkafka
4
4
  module OAuth
5
5
  # Set the OAuthBearer token
6
6
  #
7
- # @param token [String] the mandatory token value to set, often (but not necessarily) a JWS compact serialization as per https://tools.ietf.org/html/rfc7515#section-3.1.
8
- # @param lifetime_ms [Integer] when the token expires, in terms of the number of milliseconds since the epoch. See https://currentmillis.com/.
7
+ # @param token [String] the mandatory token value to set, often (but not necessarily) a JWS
8
+ # compact serialization as per https://tools.ietf.org/html/rfc7515#section-3.1.
9
+ # @param lifetime_ms [Integer] when the token expires, in terms of the number of milliseconds
10
+ # since the epoch. See https://currentmillis.com/.
9
11
  # @param principal_name [String] the mandatory Kafka principal name associated with the token.
10
- # @param extensions [Hash] optional SASL extensions key-value pairs to be communicated to the broker as additional key-value pairs during the initial client response as per https://tools.ietf.org/html/rfc7628#section-3.1.
12
+ # @param extensions [Hash] optional SASL extensions key-value pairs to be communicated to the
13
+ # broker as additional key-value pairs during the initial client response as per
14
+ # https://tools.ietf.org/html/rfc7628#section-3.1.
11
15
  # @return [Integer] 0 on success
12
16
  def oauthbearer_set_token(token:, lifetime_ms:, principal_name:, extensions: nil)
13
17
  error_buffer = FFI::MemoryPointer.from_string(" " * 256)
@@ -49,7 +53,8 @@ module Rdkafka
49
53
  # Convert extensions hash to FFI::MemoryPointer (`const char **`).
50
54
  #
51
55
  # @param extensions [Hash, nil] extension key-value pairs
52
- # @return [Array<FFI::MemoryPointer, Array<FFI::MemoryPointer>>] array pointer and string pointers
56
+ # @return [Array<FFI::MemoryPointer, Array<FFI::MemoryPointer>>] array pointer and string
57
+ # pointers
53
58
  # @note The returned pointers must be freed manually (autorelease = false).
54
59
  def map_extensions(extensions)
55
60
  return [nil, nil] if extensions.nil? || extensions.empty?
@@ -80,7 +85,7 @@ module Rdkafka
80
85
  #
81
86
  # @param extensions [Hash, nil] extension key-value pairs
82
87
  # @return [Integer] non-negative even number representing keys + values count
83
- # @see https://github.com/confluentinc/librdkafka/blob/master/src/rdkafka_sasl_oauthbearer.c#L327-L347
88
+ # @see github.com/confluentinc/librdkafka/blob/master/src/rdkafka_sasl_oauthbearer.c#L327-L347
84
89
  def extension_size(extensions)
85
90
  return 0 unless extensions
86
91
  extensions.size * 2
@@ -23,12 +23,55 @@ module Rdkafka
23
23
  # @param timeout_ms [Integer] timeout in milliseconds
24
24
  # @raise [RdkafkaError] when metadata fetch fails
25
25
  def initialize(native_client, topic_name = nil, timeout_ms = Defaults::METADATA_TIMEOUT_MS)
26
- attempt ||= 0
27
- attempt += 1
28
-
29
- native_topic = if topic_name
30
- Rdkafka::Bindings.rd_kafka_topic_new(native_client, topic_name, nil)
26
+ attempt = 0
27
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) +
28
+ Defaults::METADATA_RETRY_BUDGET_MS / 1_000.0
29
+
30
+ begin
31
+ attempt += 1
32
+ fetch_metadata(native_client, topic_name, timeout_ms)
33
+ rescue ::Rdkafka::RdkafkaError => e
34
+ raise unless RETRIED_ERRORS.include?(e.code)
35
+ raise if attempt > Defaults::METADATA_MAX_RETRIES
36
+
37
+ # Stop once the wall-clock retry budget is spent, but only after at least
38
+ # METADATA_MIN_ATTEMPTS tries so a slow broker (whose requests each consume the full
39
+ # timeout) still gets a few tries rather than being cut off after one or two.
40
+ raise if attempt >= Defaults::METADATA_MIN_ATTEMPTS &&
41
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
42
+
43
+ # Exponential backoff between attempts, capped so a long retry sequence cannot block for
44
+ # minutes. The request timeout (`timeout_ms`) is intentionally left unchanged: it used to be
45
+ # overwritten with the backoff value, which shrank the first retries below the configured
46
+ # timeout (near-guaranteeing another timeout) and then inflated later ones to ~100s.
47
+ backoff_ms = [
48
+ (2**attempt) * Defaults::METADATA_RETRY_BACKOFF_BASE_MS,
49
+ Defaults::METADATA_RETRY_BACKOFF_MAX_MS
50
+ ].min
51
+
52
+ sleep(backoff_ms / 1_000.0)
53
+
54
+ retry
31
55
  end
56
+ end
57
+
58
+ private
59
+
60
+ # Performs a single metadata fetch attempt and frees its native resources before returning.
61
+ #
62
+ # This is intentionally separate from the retry loop in {#initialize}: `retry` restarts the
63
+ # `begin` block without running its `ensure`, so cleaning up here (rather than in an `ensure`
64
+ # around the loop) guarantees every attempt destroys its own native topic handle and metadata
65
+ # struct instead of leaking all but the last one.
66
+ #
67
+ # @param native_client [FFI::Pointer] pointer to the native Kafka client
68
+ # @param topic_name [String, nil] specific topic to fetch metadata for, or nil for all topics
69
+ # @param timeout_ms [Integer] timeout in milliseconds
70
+ def fetch_metadata(native_client, topic_name, timeout_ms)
71
+ native_topic = nil
72
+ metadata_ptr = nil
73
+
74
+ native_topic = Rdkafka::Bindings.rd_kafka_topic_new(native_client, topic_name, nil) if topic_name
32
75
 
33
76
  ptr = FFI::MemoryPointer.new(:pointer)
34
77
 
@@ -41,24 +84,16 @@ module Rdkafka
41
84
 
42
85
  Rdkafka::RdkafkaError.validate!(result)
43
86
 
44
- metadata_from_native(ptr.read_pointer)
45
- rescue ::Rdkafka::RdkafkaError => e
46
- raise unless RETRIED_ERRORS.include?(e.code)
47
- raise if attempt > Defaults::METADATA_MAX_RETRIES
87
+ # rd_kafka_metadata only allocates the struct on success, so we read the pointer to destroy
88
+ # only after validate! has confirmed the call succeeded.
89
+ metadata_ptr = ptr.read_pointer
48
90
 
49
- backoff_factor = 2**attempt
50
- timeout_ms = backoff_factor * Defaults::METADATA_RETRY_BACKOFF_BASE_MS
51
-
52
- sleep(timeout_ms / 1_000.0)
53
-
54
- retry
91
+ metadata_from_native(metadata_ptr)
55
92
  ensure
56
- Rdkafka::Bindings.rd_kafka_topic_destroy(native_topic) if topic_name
57
- Rdkafka::Bindings.rd_kafka_metadata_destroy(ptr.read_pointer)
93
+ Rdkafka::Bindings.rd_kafka_topic_destroy(native_topic) if native_topic
94
+ Rdkafka::Bindings.rd_kafka_metadata_destroy(metadata_ptr) if metadata_ptr && !metadata_ptr.null?
58
95
  end
59
96
 
60
- private
61
-
62
97
  # Extracts metadata from native pointer
63
98
  # @param ptr [FFI::Pointer] pointer to native metadata
64
99
  def metadata_from_native(ptr)
@@ -134,6 +169,36 @@ module Rdkafka
134
169
  :replicas, :pointer,
135
170
  :in_sync_replica_brokers, :int,
136
171
  :isrs, :pointer
172
+
173
+ # The base `#to_h` skips FFI pointer members, which would drop the replica and in-sync
174
+ # replica assignments entirely. We dereference those pointers here so the partition hash
175
+ # exposes the broker ids backing the partition (needed e.g. to plan replication changes).
176
+ #
177
+ # @return [Hash{Symbol => Integer, Array<Integer>}] partition metadata:
178
+ # * +:partition_id+ (Integer) - partition id
179
+ # * +:leader+ (Integer) - broker id of the partition leader
180
+ # * +:replica_count+ (Integer) - number of assigned replicas
181
+ # * +:in_sync_replica_brokers+ (Integer) - number of in-sync replicas
182
+ # * +:replicas+ (Array<Integer>) - broker ids of the assigned replicas
183
+ # * +:isrs+ (Array<Integer>) - broker ids of the in-sync replicas
184
+ def to_h
185
+ super.merge(
186
+ replicas: read_broker_ids(self[:replicas], self[:replica_count]),
187
+ isrs: read_broker_ids(self[:isrs], self[:in_sync_replica_brokers])
188
+ )
189
+ end
190
+
191
+ private
192
+
193
+ # Reads `count` broker ids (int32) from a replicas/isrs pointer.
194
+ # @param pointer [FFI::Pointer] pointer to the broker ids array
195
+ # @param count [Integer] number of broker ids to read
196
+ # @return [Array<Integer>] broker ids (empty when there are none)
197
+ def read_broker_ids(pointer, count)
198
+ return [] if count.zero? || pointer.null?
199
+
200
+ pointer.read_array_of_int32(count)
201
+ end
137
202
  end
138
203
  end
139
204
  end
@@ -13,6 +13,14 @@ module Rdkafka
13
13
  def initialize(inner, run_polling_thread:, opaque:, auto_start: true, timeout_ms: Defaults::NATIVE_KAFKA_POLL_TIMEOUT_MS)
14
14
  @inner = inner
15
15
  @opaque = opaque
16
+ # Process that owns `@inner`. librdkafka is not fork-safe: `fork` copies only the calling
17
+ # thread, so the background/broker threads backing this handle do not exist in a child
18
+ # process. An inherited handle therefore must not be polled or destroyed in the child -
19
+ # `rd_kafka_destroy` would walk thread state that no longer exists (segfault) and the
20
+ # inherited mutexes may have been copied in a locked state (deadlock). We record the creator
21
+ # pid so a forked child can recognise an inherited handle and leave its teardown to the
22
+ # parent, which still owns the running threads.
23
+ @creator_pid = Process.pid
16
24
  # Lock around external access
17
25
  @access_mutex = Mutex.new
18
26
  # Lock around internal polling
@@ -116,17 +124,21 @@ module Rdkafka
116
124
  end
117
125
 
118
126
  # Returns whether this native Kafka handle is closed or closing
127
+ #
128
+ # A handle inherited across `fork` is reported as closed in the child: it belongs to another
129
+ # process whose threads back the native client, so it is not usable here and must not be
130
+ # destroyed here (see the `@creator_pid` note in `#initialize`). This makes every `#close`
131
+ # path - including the GC finalizers that run during a child's exit - skip the native teardown
132
+ # for inherited handles, which is what would otherwise segfault the child.
133
+ #
119
134
  # @return [Boolean] true if closed or closing
120
135
  def closed?
121
- @closing || @inner.nil?
136
+ @closing || @inner.nil? || @creator_pid != Process.pid
122
137
  end
123
138
 
124
139
  # Enable IO event notifications on the main queue
125
140
  # Librdkafka will write to your FD when the queue transitions from empty to non-empty
126
141
  #
127
- # @note This method is incompatible with background polling threads.
128
- # If background polling is enabled, use manual polling instead (e.g., consumer.poll)
129
- #
130
142
  # @param fd [Integer] your file descriptor (from IO.pipe or eventfd)
131
143
  # @param payload [String] data to write to fd when queue has data (default: "\x01")
132
144
  # @return [nil]
@@ -143,6 +155,8 @@ module Rdkafka
143
155
  # if readable
144
156
  # consumer.poll(0) # Get messages
145
157
  # end
158
+ # @note This method is incompatible with background polling threads.
159
+ # If background polling is enabled, use manual polling instead (e.g., consumer.poll)
146
160
  def enable_main_queue_io_events(fd, payload = "\x01")
147
161
  if @run_polling_thread
148
162
  raise "Cannot enable IO events while background polling thread is active. " \
@@ -160,14 +174,13 @@ module Rdkafka
160
174
  # Enable IO event notifications on the background queue
161
175
  # Librdkafka will write to your FD when the background queue transitions from empty to non-empty
162
176
  #
163
- # @note This method is incompatible with background polling threads.
164
- # If background polling is enabled, use manual polling instead (e.g., consumer.poll)
165
- #
166
177
  # @param fd [Integer] your file descriptor (from IO.pipe or eventfd)
167
178
  # @param payload [String] data to write to fd when queue has data (default: "\x01")
168
179
  # @return [nil]
169
180
  # @raise [ClosedInnerError] when the handle is closed
170
181
  # @raise [RuntimeError] when background polling thread is active
182
+ # @note This method is incompatible with background polling threads.
183
+ # If background polling is enabled, use manual polling instead (e.g., consumer.poll)
171
184
  def enable_background_queue_io_events(fd, payload = "\x01")
172
185
  if @run_polling_thread
173
186
  raise "Cannot enable IO events while background polling thread is active. " \
@@ -2,20 +2,19 @@
2
2
 
3
3
  module Rdkafka
4
4
  class Producer
5
- # Handle to wait for a delivery report which is returned when
6
- # producing a message.
5
+ # Handle to wait for a delivery report which is returned when producing a message.
7
6
  class DeliveryHandle < Rdkafka::AbstractHandle
8
7
  layout :pending, :bool,
9
8
  :response, :int,
10
9
  :partition, :int,
11
- :offset, :int64,
12
- :topic_name, :pointer
10
+ :offset, :int64
13
11
 
14
12
  # @return [Object, nil] label set during message production or nil by default
15
13
  attr_accessor :label
16
14
 
17
15
  # @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
16
+ # Set in `#produce`, where the topic is known upfront. Keeping it as a Ruby attribute
17
+ # spares a per-message native string copy in the delivery callback.
19
18
  attr_accessor :topic
20
19
 
21
20
  # @return [String] the name of the operation (e.g. "delivery")
@@ -37,12 +37,11 @@ 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
 
@@ -91,28 +90,14 @@ module Rdkafka
91
90
  current_info = @counts[topic]
92
91
 
93
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.
94
97
  new_count = yield
98
+ set(topic, new_count)
95
99
 
96
- if current_info.nil?
97
- # No existing data, create a new entry with mutex
98
- set(topic, new_count)
99
-
100
- return new_count
101
- else
102
- current_count = current_info[1]
103
-
104
- if new_count > current_count
105
- # Higher value needs mutex to update both timestamp and count
106
- set(topic, new_count)
107
-
108
- return new_count
109
- else
110
- # Same or lower value, just update timestamp without mutex
111
- refresh_timestamp(topic)
112
-
113
- return current_count
114
- end
115
- end
100
+ return @counts[topic][1]
116
101
  end
117
102
 
118
103
  current_info[1]
@@ -133,8 +118,11 @@ module Rdkafka
133
118
  # First check outside mutex to avoid unnecessary locking
134
119
  current_info = @counts[topic]
135
120
 
136
- # For lower values, we don't update count but might need to refresh timestamp
137
- 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])
138
126
  refresh_timestamp(topic)
139
127
 
140
128
  return
@@ -148,17 +136,15 @@ module Rdkafka
148
136
  if current_info.nil?
149
137
  # Create new entry
150
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
151
145
  else
152
- current_count = current_info[1]
153
-
154
- if new_count > current_count
155
- # Update to higher count value
156
- current_info[0] = monotonic_now_ms
157
- current_info[1] = new_count
158
- else
159
- # Same or lower count, update timestamp only
160
- current_info[0] = monotonic_now_ms
161
- end
146
+ # Same or lower count within the TTL window: refresh the timestamp only
147
+ current_info[0] = monotonic_now_ms
162
148
  end
163
149
  end
164
150
  end
@@ -36,7 +36,8 @@ module Rdkafka
36
36
  # Checks if a fatal error has occurred and retrieves error details.
37
37
  # Calls rd_kafka_fatal_error to get the actual fatal error code and message.
38
38
  #
39
- # @return [Hash, nil] Hash with :error_code and :error_string if fatal error occurred, nil otherwise
39
+ # @return [Hash, nil] Hash with :error_code and :error_string if fatal error occurred,
40
+ # nil otherwise
40
41
  #
41
42
  # @example
42
43
  # if fatal_error = producer.fatal_error