waterdrop 2.10.2 → 2.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1329d22d7f4f960b2df24949a040dd1e1b57ec73002ed779f3bcd0c5ad4dad20
4
- data.tar.gz: b2b9868379d00dd3951df5a2e3b45bdce1b24295284fd5cb6cca410709a38a05
3
+ metadata.gz: ec39a827b8ea9216ef39589a8ce2c8261ce29b5ba1386ddb513c2ca393b5ade1
4
+ data.tar.gz: d37a7617f72d101f75c5d02f4c77627757ad5254322ecbf8bf1c30b0b502fb98
5
5
  SHA512:
6
- metadata.gz: 817244d3151a463cca84a1a7fefd298c5e355337325817430cdef5f5eb1c09013d20f801f9ef95b5e9182539f8cb550f25df62042bf93bc7826e9f50ee4b1caf
7
- data.tar.gz: c60cc27e7b3787a9610229dacdcbe0440fa7332c5659f5e61348409b4ca2c507718a5bbd93bf5e088f8cc7a5d8b91da981eac0f377257ec0c4394e20dcd92994
6
+ metadata.gz: e65469f5af00b6976f8d6bcc7efecbb8bc7ce3e84418928d6364197c2b84c600730bb36cfa0ffd2333ad7a726367406aea69fa56d9ecc796b1a31a8b5cb11f94
7
+ data.tar.gz: 654c121690450afdbb356527cbfb142bacc0b7eddbd3dd0c69017d5564cbdeb0712647cd8b13c27180a7cc94a23cca4049d748935e0ca6ccef068f5f55c079d7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # WaterDrop changelog
2
2
 
3
+ ## 2.10.3 (2026-07-15)
4
+ - [Feature] Add `wait_timeout_on_transaction_abort` (default `0`, disabled) - an opt-in mitigation for [librdkafka#4849](https://github.com/confluentinc/librdkafka/issues/4849). librdkafka only marks a transaction as ongoing at the coordinator once the `AddPartitionsToTxn` **response** arrives, but it fires `EndTxn` as soon as that request has merely been **sent**. Aborting while the first produce is still in flight can therefore reach a coordinator that does not yet consider the transaction started, failing the abort with a fatal `INVALID_TXN_STATE` that poisons the client and forces a full reload. When set to a positive value, we wait for the first delivery of the transaction to be acknowledged before aborting, for at most that long (in ms) - it is a ceiling rather than a fixed delay, so the wait ends as soon as the delivery is acknowledged. A single ack proves its partition completed registration, which is enough for the coordinator to accept `EndTxn` regardless of how many partitions the transaction spans. It is off by default because it changes abort semantics: the awaited message is really written to the log (aborted, hence invisible to `read_committed` consumers) instead of being purged, so its delivery handle reports a real offset rather than a `Purged in queue` error and no `message.purged` is emitted for it. The wait is bounded and best-effort - if it expires we abort exactly as before, and the fatal remains recoverable through `reload_on_transaction_fatal_error`.
5
+
3
6
  ## 2.10.2 (2026-06-15)
4
7
  - [Feature] Expose `Producer#current_variant` as a public method. It returns the variant active for the current dispatch on the current fiber - the custom variant while inside a `#with`/`#variant`-wrapped call, otherwise the producer's default variant - so middleware and instrumentation listeners running synchronously within a dispatch can read the effective per-dispatch settings (`topic_config`, `max_wait_timeout`, `default?`). The lookup is fiber-local and dispatch-scoped: outside a variant-wrapped call (or from an asynchronous delivery callback) it returns the default variant.
5
8
  - [Enhancement] Stop allocating one interpolated string per message in `LoggerListener` batch produce handlers. The quoted topic strings were only ever counted (quoting is a 1:1 mapping), never displayed, so counting the raw topic values yields the identical number with zero string allocations - relevant for large `produce_many_*` batches with the default logger listener attached.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- waterdrop (2.10.2)
4
+ waterdrop (2.10.3)
5
5
  karafka-core (>= 2.5.12, < 3.0.0)
6
6
  karafka-rdkafka (>= 0.24.0)
7
7
  zeitwerk (~> 2.3)
@@ -12,12 +12,11 @@ GEM
12
12
  byebug (13.0.0)
13
13
  reline (>= 0.6.0)
14
14
  connection_pool (3.0.2)
15
- docile (1.4.1)
16
15
  drb (2.2.3)
17
16
  ffi (1.17.4)
18
17
  io-console (0.8.2)
19
- json (2.19.7)
20
- karafka-core (2.5.13)
18
+ json (2.20.0)
19
+ karafka-core (2.6.2)
21
20
  karafka-rdkafka (>= 0.20.0)
22
21
  logger (>= 1.6.0)
23
22
  karafka-rdkafka (0.27.2)
@@ -39,12 +38,7 @@ GEM
39
38
  reline (0.6.3)
40
39
  io-console (~> 0.5)
41
40
  ruby2_keywords (0.0.5)
42
- simplecov (0.22.0)
43
- docile (~> 1.1)
44
- simplecov-html (~> 0.11)
45
- simplecov_json_formatter (~> 0.1)
46
- simplecov-html (0.13.2)
47
- simplecov_json_formatter (0.1.4)
41
+ simplecov (1.0.0)
48
42
  warning (1.6.0)
49
43
  zeitwerk (2.8.2)
50
44
 
@@ -65,12 +59,11 @@ DEPENDENCIES
65
59
  CHECKSUMS
66
60
  byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d
67
61
  connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
68
- docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
69
62
  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
70
63
  ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
71
64
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
72
- json (2.19.7) sha256=fe432c8639f6efff69f9d73b518a3705d9581ab93156f981ea72806e1e5bcc3e
73
- karafka-core (2.5.13) sha256=0acec083043bb6166c4b647a7458091cc7b08066d3b92a026932925ec7e07f61
65
+ json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b
66
+ karafka-core (2.6.2) sha256=c2fd7f277201b8ca97b824b364ad76bf776b8f5527bc422dafb71f7ae48d3a13
74
67
  karafka-rdkafka (0.27.2) sha256=3ccce96306642be70bff8168e4e737fc10f2ffae20bc0ff0a43d88dbb7452d31
75
68
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
76
69
  mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
@@ -81,11 +74,9 @@ CHECKSUMS
81
74
  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
82
75
  reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
83
76
  ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
84
- simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
85
- simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
86
- simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
77
+ simplecov (1.0.0) sha256=bdc50b41fa4a8b3c860da4bf5d61ba4704479c51d08a8f53cbd56ae50778dca8
87
78
  warning (1.6.0) sha256=a49cdfae19fb77d19afff2efbe45f8ab759e9cd25b4e4ce2c79dbaf46bdb6c9e
88
- waterdrop (2.10.2)
79
+ waterdrop (2.10.3)
89
80
  zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12
90
81
 
91
82
  BUNDLED WITH
@@ -7,6 +7,18 @@ allowed_patterns=(
7
7
  "Performing controller activation"
8
8
  "registered with feature metadata.version"
9
9
  "TOPIC_ALREADY_EXISTS"
10
+ # The transactional_abort_after_commit_race and transactional_abort_race_canary integration specs
11
+ # deliberately reproduce librdkafka#4849: they abort with the first produce still in flight, so
12
+ # the abort's EndTxn can reach the coordinator before AddPartitionsToTxn registers the new
13
+ # transaction. The coordinator then still holds the *previous* transaction's COMPLETE_COMMIT state
14
+ # and rejects the ABORT marker, logging this warning. It is the broker-side signature of the very
15
+ # defect those specs exist to reproduce, so it is expected there - and scoped to their
16
+ # transactional ids so we still catch the same warning anywhere else.
17
+ #
18
+ # One entry per spec rather than an alternation: `\|` is a GNU extension to POSIX BRE, so on BSD
19
+ # grep (macOS) it would match nothing and the allowance would silently do nothing at all.
20
+ "tx-abort-race-id.*received transaction marker result to send: ABORT"
21
+ "tx-abort-canary-id.*received transaction marker result to send: ABORT"
10
22
  )
11
23
 
12
24
  # Get all warnings
@@ -18,7 +18,7 @@ services:
18
18
  start_period: 90s
19
19
 
20
20
  kafka-oauth:
21
- image: confluentinc/cp-kafka:8.2.1
21
+ image: confluentinc/cp-kafka:8.3.0
22
22
  container_name: kafka-oauth
23
23
  depends_on:
24
24
  keycloak:
@@ -1,6 +1,6 @@
1
1
  services:
2
2
  kafka-sasl:
3
- image: confluentinc/cp-kafka:8.2.1
3
+ image: confluentinc/cp-kafka:8.3.0
4
4
  container_name: kafka-sasl
5
5
  ports:
6
6
  - "9095:9095"
data/docker-compose.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  services:
2
2
  kafka:
3
3
  container_name: kafka
4
- image: confluentinc/cp-kafka:8.2.1
4
+ image: confluentinc/cp-kafka:8.3.0
5
5
 
6
6
  ports:
7
7
  - 9092:9092
@@ -95,6 +95,35 @@ module WaterDrop
95
95
  # option [Integer] How many times to attempt reloading on transactional fatal error before
96
96
  # giving up. This prevents infinite reload loops if the producer never recovers.
97
97
  setting :max_attempts_on_transaction_fatal_error, default: 10
98
+ # option [Numeric] How long to wait **at most** (in ms) for the first delivery of a transaction
99
+ # to be acknowledged before we abort that transaction. `0` (the default) disables the wait.
100
+ #
101
+ # This is a ceiling, not a fixed delay: the wait ends as soon as the delivery is acknowledged,
102
+ # which under normal conditions is immediate. The full timeout is only ever spent when the
103
+ # delivery never arrives at all.
104
+ #
105
+ # This is an opt-in mitigation for a librdkafka defect. librdkafka only flags a transaction as
106
+ # ongoing at the coordinator once the `AddPartitionsToTxn` **response** arrives, but it sends
107
+ # `EndTxn` as soon as that request has merely been **sent**. Aborting with the first produce
108
+ # still in flight can therefore hit a coordinator that does not consider the transaction
109
+ # started yet, failing the abort with a fatal `INVALID_TXN_STATE`.
110
+ # See https://github.com/confluentinc/librdkafka/issues/4849
111
+ #
112
+ # A single acknowledged delivery proves its partition completed registration, and that alone
113
+ # makes the coordinator accept `EndTxn` - no matter how many partitions the transaction spans.
114
+ # So waiting for one delivery before aborting closes the race.
115
+ #
116
+ # It is off by default because it changes abort semantics: waiting for that ack means the
117
+ # first message is actually **delivered** (aborted, so invisible to `read_committed`
118
+ # consumers) instead of being **purged**. Its delivery handle then reports a real offset
119
+ # rather than a `Purged in queue` error, and no `message.purged` event is emitted for it,
120
+ # while the remaining messages of the same transaction are still purged. Enable this only if
121
+ # you hit the defect and prefer that trade over the fatal (which stays recoverable through
122
+ # `reload_on_transaction_fatal_error` either way).
123
+ #
124
+ # The wait is bounded and best-effort: if it expires (broker down, message timeout) we abort
125
+ # exactly as if it were disabled.
126
+ setting :wait_timeout_on_transaction_abort, default: 0
98
127
  # option [Array<Symbol>] List of fatal error codes that should NOT trigger producer reload.
99
128
  # These errors represent states that cannot be recovered by simply recreating the client.
100
129
  #
@@ -30,6 +30,7 @@ module WaterDrop
30
30
  required(:max_attempts_on_idempotent_fatal_error) { |val| val.is_a?(Integer) && val >= 1 }
31
31
  required(:wait_backoff_on_transaction_fatal_error) { |val| val.is_a?(Numeric) && val >= 0 }
32
32
  required(:max_attempts_on_transaction_fatal_error) { |val| val.is_a?(Integer) && val >= 1 }
33
+ required(:wait_timeout_on_transaction_abort) { |val| val.is_a?(Numeric) && val >= 0 }
33
34
  required(:non_reloadable_errors) do |val|
34
35
  val.is_a?(Array) && val.all?(Symbol)
35
36
  end
@@ -106,9 +106,11 @@ module WaterDrop
106
106
  # This is why we catch this here
107
107
  begin
108
108
  with_transactional_error_handling(:abort) do
109
- transactional_instrument(:aborted) do
110
- client.abort_transaction
111
- end
109
+ # Outside of the `aborted` instrumentation on purpose: that event is supposed to
110
+ # measure `client.abort_transaction`, not our wait
111
+ transactional_await_first_delivery(e)
112
+
113
+ transactional_instrument(:aborted) { client.abort_transaction }
112
114
  end
113
115
  rescue => e
114
116
  # If something from rdkafka leaks here, it means there was a non-retryable error that
@@ -121,6 +123,13 @@ module WaterDrop
121
123
  transactional_reload_client_if_needed(e)
122
124
 
123
125
  raise unless e.is_a?(WaterDrop::Errors::AbortTransaction)
126
+ ensure
127
+ # The first delivery handle is scoped to the transaction that produced it and is only
128
+ # ever read by the abort path (which runs in the rescue above, before this). Clearing it
129
+ # here - on commit, on abort and on a re-raise alike - keeps it from outliving its
130
+ # transaction: otherwise a committed transaction would leave its handle (and everything
131
+ # the delivery report references) pinned to the producer until the next one begins.
132
+ @transaction_first_handle = nil
124
133
  end
125
134
  end
126
135
  end
@@ -255,6 +264,12 @@ module WaterDrop
255
264
  if e.abortable? && allow_abortable
256
265
  # Always attempt to abort but if aborting fails with an abortable error, do not attempt
257
266
  # to abort from abort as this could create an infinite loop
267
+ #
268
+ # We deliberately do NOT wait for the first delivery here (unlike the abort in
269
+ # `#transaction`): we only get here because librdkafka reported an abortable error, so the
270
+ # transaction is already in an error state where it no longer delivers. The queued messages
271
+ # are purged by the abort itself, so the handle would never resolve and we would just stall
272
+ # for the whole `wait_timeout_on_transaction_abort` on a path that is already recovering.
258
273
  with_transactional_error_handling(:abort, allow_abortable: false) do
259
274
  transactional_instrument(:aborted) { client.abort_transaction }
260
275
  end
@@ -263,6 +278,61 @@ module WaterDrop
263
278
  raise
264
279
  end
265
280
 
281
+ # Waits (bounded) for the first delivery of the current transaction before we abort it.
282
+ #
283
+ # librdkafka only marks a transaction as ongoing at the coordinator once the
284
+ # `AddPartitionsToTxn` **response** comes back, but it fires `EndTxn` as soon as that request
285
+ # has merely been **sent** (it gates on `txn_req_cnt`, bumped on send). Aborting with the first
286
+ # produce still in flight can therefore reach a coordinator that does not yet consider the
287
+ # transaction started, which fails the abort with a fatal `INVALID_TXN_STATE`.
288
+ # See https://github.com/confluentinc/librdkafka/issues/4849
289
+ #
290
+ # A delivered message proves its partition completed registration, and that alone puts the
291
+ # transaction in an `ongoing` state at the coordinator - so a single acknowledged delivery is
292
+ # enough to make `EndTxn` valid, regardless of how many partitions the transaction spans.
293
+ #
294
+ # This is best-effort and never fatal: if the delivery does not materialize within the timeout
295
+ # (broker down, message timeout, purge) we abort exactly as before rather than hanging. The
296
+ # fatal, should it still happen, remains recoverable through the client reload.
297
+ #
298
+ # @param error [Exception] the error that is causing us to abort this transaction
299
+ def transactional_await_first_delivery(error)
300
+ handle = @transaction_first_handle
301
+ timeout = config.wait_timeout_on_transaction_abort
302
+
303
+ return if handle.nil?
304
+ return if timeout.zero?
305
+ # Only worth waiting when the transaction is still healthy, that is when we abort because the
306
+ # user asked us to (`AbortTransaction`) or because their own code raised. Once librdkafka
307
+ # itself reported an error, the transaction no longer delivers: the queued messages are
308
+ # purged by the abort, so this handle would never resolve and we would stall for the full
309
+ # timeout on a path that is already recovering from an error.
310
+ return if transactional_error?(error)
311
+
312
+ # An already delivered handle (the common case - any sync dispatch, or a transaction that did
313
+ # some work before aborting) short-circuits inside `#wait` itself, so there is nothing to
314
+ # guard against here: it returns immediately without blocking.
315
+ wait(handle, max_wait_timeout: timeout, raise_response_error: false)
316
+ rescue ::Rdkafka::AbstractHandle::WaitTimeoutError, ::Rdkafka::RdkafkaError
317
+ # These two mean the same thing for us: we could not confirm the registration, either because
318
+ # the delivery did not arrive in time or because it failed outright. Aborting is still the
319
+ # right move, we just lose the mitigation for this one transaction.
320
+ #
321
+ # Deliberately narrow. A broader rescue would also swallow bugs of our own making (a
322
+ # `NoMethodError` on a handle we mis-tracked, say) and turn them into a silently skipped
323
+ # mitigation that nobody would ever notice.
324
+ nil
325
+ end
326
+
327
+ # @param error [Exception] error that caused the abort
328
+ # @return [Boolean] did this error come from librdkafka, meaning the transaction is already in
329
+ # an error state and will not deliver anything anymore
330
+ def transactional_error?(error)
331
+ return true if error.is_a?(::Rdkafka::RdkafkaError)
332
+
333
+ error.cause.is_a?(::Rdkafka::RdkafkaError)
334
+ end
335
+
266
336
  # Reloads the underlying client instance if needed and allowed
267
337
  #
268
338
  # This should be used only in transactions as only then we can get fatal transactional
@@ -67,6 +67,7 @@ module WaterDrop
67
67
  @poller = nil
68
68
  @idempotent_fatal_error_attempts = 0
69
69
  @transaction_fatal_error_attempts = 0
70
+ @transaction_first_handle = nil
70
71
 
71
72
  @status = Status.new
72
73
  @messages = []
@@ -647,6 +648,12 @@ module WaterDrop
647
648
  client.produce(**message)
648
649
  end
649
650
 
651
+ # Remember the first delivery handle of the current transaction. Aborting while the very first
652
+ # produce is still in flight is what triggers librdkafka#4849, so the abort path waits on this
653
+ # handle to confirm the transaction is registered at the coordinator. See
654
+ # `#transactional_await_first_delivery`.
655
+ @transaction_first_handle ||= result if transactional? && @transaction_mutex.owned?
656
+
650
657
  # Reset attempts counter on successful produce
651
658
  @idempotent_fatal_error_attempts = 0
652
659
 
@@ -3,5 +3,5 @@
3
3
  # WaterDrop library
4
4
  module WaterDrop
5
5
  # Current WaterDrop version
6
- VERSION = "2.10.2"
6
+ VERSION = "2.10.3"
7
7
  end
data/package-lock.json CHANGED
@@ -286,9 +286,9 @@
286
286
  }
287
287
  },
288
288
  "node_modules/smol-toml": {
289
- "version": "1.6.1",
290
- "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
291
- "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
289
+ "version": "1.7.0",
290
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
291
+ "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
292
292
  "dev": true,
293
293
  "license": "BSD-3-Clause",
294
294
  "engines": {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: waterdrop
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.10.2
4
+ version: 2.10.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld