waterdrop 2.10.1 → 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: 9574fc03b9e00ef31c260706c2bb48022f909709deeae51e4a952e38ab177256
4
- data.tar.gz: a0b49f8634f64d744162330488cefec6472e60123d472e266a362897212673ad
3
+ metadata.gz: ec39a827b8ea9216ef39589a8ce2c8261ce29b5ba1386ddb513c2ca393b5ade1
4
+ data.tar.gz: d37a7617f72d101f75c5d02f4c77627757ad5254322ecbf8bf1c30b0b502fb98
5
5
  SHA512:
6
- metadata.gz: 6bd10b69a26441486c2cb95ba75fb986372df75e38612c6cdf3a13a1f22fb9cb06dcfe8eb7c13fb31891c9a0d656bba48374cd194af7a676c42a7f44ec402ff7
7
- data.tar.gz: d6378265af2d9bff9d8acbda9ade3500d744a31f377e95b7129aa18711c3e673a5b61d147b1d2308fd44d6ac4db8958311cb57427fb97b31e2b29a2c3e2d8943
6
+ metadata.gz: e65469f5af00b6976f8d6bcc7efecbb8bc7ce3e84418928d6364197c2b84c600730bb36cfa0ffd2333ad7a726367406aea69fa56d9ecc796b1a31a8b5cb11f94
7
+ data.tar.gz: 654c121690450afdbb356527cbfb142bacc0b7eddbd3dd0c69017d5564cbdeb0712647cd8b13c27180a7cc94a23cca4049d748935e0ca6ccef068f5f55c079d7
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.4
1
+ 4.0.5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
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
+
6
+ ## 2.10.2 (2026-06-15)
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.
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.
9
+ - [Enhancement] Use `Array#concat` in `Producer#buffer_many` instead of appending messages one by one.
10
+ - [Enhancement] Skip building the `message.acknowledged` instrumentation payload in the delivery callback when nothing is subscribed to that event. The notifications bus already short-circuits on empty listeners, but only after the payload hash was allocated - once per delivered message on the polling thread. Mirrors the listener guard already used by the statistics callback. Late subscribers keep working as the check happens on each emission.
11
+ - [Enhancement] Resolve the fiber-local variant once per `#produce` call and once per `#produce_many_sync` wait phase instead of re-resolving it for every usage and for every waited delivery handle. For a 1,000-message sync batch this removes ~2,000 redundant fiber-local lookups.
12
+ - [Enhancement] Do not allocate the fiber-local variants hash on the `Producer#current_variant` read path. Previously every fiber that produced messages got a Hash pinned to it for the fiber's lifetime (per producer use), even when variants were never used - wasteful under fiber-per-request servers (Falcon, async). The hash is now only created by variant wrapper methods that actually need to write to it.
13
+ - [Enhancement] Cache the variant validation contract in a constant instead of instantiating a new `Contracts::Variant` on every `Producer#with` / `Producer#variant` call (mirrors the existing `Transactions::CONTRACT` pattern).
14
+ - [Enhancement] Cache the tombstone validation contract in a constant instead of instantiating a new `Contracts::Tombstone` per tombstone message, removing per-message allocations in the `tombstone_*` APIs (mirrors the existing `Transactions::CONTRACT` pattern).
15
+ - [Enhancement] Replace explicit `Warning[:performance]` opt-in with a dynamic approach using `Warning.categories` (available since Ruby 3.4) to automatically enable all stable opt-in warning categories in the test suite, including `:strict_unused_block` introduced in Ruby 4.0.
16
+ - [Fix] Prevent a deadlock between a transactional single-message dispatch and `#close`. A single `produce_sync`/`produce_async` on a transactional producer incremented the operations counter (which `#close` drains while holding `@transaction_mutex`) before acquiring `@transaction_mutex` for its per-message transaction - an inverted lock order. A dispatch that had counted itself but not yet taken `@transaction_mutex` could deadlock a concurrent `#close` permanently (the close wait loop has no timeout). Transactional dispatches now take `@transaction_mutex` before the operation is counted, matching `#close`'s lock order (`@transaction_mutex` -> `@operating_mutex` -> operations counter).
17
+ - [Fix] Prevent a deadlock (`ThreadError: deadlock; recursive locking`) when closing an idempotent producer (with `reload_on_idempotent_fatal_error` enabled) that has buffered messages whose final flush surfaces a fatal librdkafka error. `#close` performs the final flush while already holding `@operating_mutex`, and the idempotent fatal-error reload tried to re-acquire that same mutex, leaving the producer stuck in `:closing` with the native client leaked. The idempotent reload is now skipped on the closing path, and the final buffer flush is best-effort so client teardown always completes.
18
+ - [Fix] Make concurrent idempotent fatal-error reload thread-safe. When several threads shared an idempotent producer (with `reload_on_idempotent_fatal_error` enabled), a single fatal librdkafka condition failed all their in-flight produces at once and each entered the reload path; the second reload ran `reload!` after the first had already reset `@client` to `nil`, raising `NoMethodError`. The idempotent reload now bails out if another thread already reloaded (mirroring the transactional path's `return if @status.configured?` guard). Additionally, `Status#active?` now classifies the lifecycle from a single atomic read and `Producer#ensure_active!` branches on one snapshot, so a concurrent `configured -> connected` transition during a reload can no longer make `ensure_active!` raise `StatusInvalidError` for a valid, active producer.
19
+ - [Fix] Stop `#flush_async` / `#flush_sync` from silently dropping valid buffered messages when the dispatch fails. `#flush` removes the batch from the internal buffer before dispatching it, and a failure (a single invalid message failing validation before anything is sent, or a mid-batch inline error such as queue full) previously discarded the entire taken batch - the removed messages were never restored. A failed flush now re-buffers the messages that never reached librdkafka (the whole batch on validation failure or on a transactional rollback, the unsent remainder otherwise) so they can be retried instead of being lost.
20
+ - [Fix] Make `Producer#close` fork-safe so the GC finalizer inherited by a forked child can no longer close the parent's client. `#client` registers an `ObjectSpace` finalizer that calls `#close`; that finalizer is inherited across `fork`, and a child that inherited a used producer, never touched it, and exited normally would run `#close` in the child - flushing and closing (with the real rdkafka client, `rd_kafka_destroy` on a fork-inherited handle, i.e. undefined behavior) a client owned by the parent. `#close` now detects when it runs in a process other than the one that built the client, drops the inherited references and finalizer, and returns without touching the native client (matching the existing fork guard on the `#client` path).
21
+ - [Fix] Guard the internal buffer appends in `Producer#buffer` and `Producer#buffer_many` with `@buffer_mutex`. The appends mutated the shared `@messages` buffer without the lock that `flush`/`purge`/`close` hold while swapping it for a fresh array, so a concurrent swap landing between reading `@messages` and appending could drop the message into an orphaned array that is never dispatched - silently losing buffered messages in the documented "buffer in one thread, flush in another" pattern.
22
+ - [Fix] Stop a nested same-producer variant call from clobbering the outer variant inside a variant `transaction` block. `transaction` is the only variant-wrapped method that yields user code, so a variant call nested inside it (another `variant.produce_*`, or a raw producer dispatch in the same scope) used to delete the shared `Fiber.current.waterdrop_clients` entry on return, making the rest of the block silently fall back to the default variant and dispatch with default `topic_config` (timeouts, compression, partitioner) instead of the altered one. The wrapper now saves and restores the previous entry instead of unconditionally deleting it (still deleting when there was none, so the fiber-local hash does not accumulate stale keys).
23
+ - [Fix] Stop `ConnectionPool#shutdown` and `#reload` from silently dropping in-flight messages. Both closed every pooled producer with `close!` (force), which flushes for the max wait timeout and then purges whatever has not drained - so on a slow or unreachable broker, queued `produce_async` messages were cancelled and lost with no delivery report. They now close producers gracefully by default (`#reload` always; `#shutdown` unless called with the new `force: true`), letting messages flush instead of being purged. Pass `pool.shutdown(force: true)` to keep the old force-and-purge behavior.
24
+ - [Fix] Close a race in the FD poller where a producer registered while the last one was being torn down could be left permanently unpolled (sync produces hang until timeout, async deliveries are never acknowledged). The poller thread decided to exit (last producer unregistered) and cleared its thread reference in two separate, unsynchronized steps, so a `register` landing in that gap saw the still-alive exiting thread, skipped starting a fresh one, and then had its producer's state closed by the exiting thread's cleanup. The thread now decides to stop and clears its reference in a single mutex section, so a racing `register` either keeps it running or starts a fresh thread; and the exit cleanup runs only on an abnormal exit, since a normal exit always leaves an empty registry and so can never close a producer registered in the gap.
25
+
3
26
  ## 2.10.1 (2026-05-25)
4
27
  - [Fix] Prevent `Producer#close` from raising `ThreadError: can't be called from trap context` when invoked from a Ruby signal trap context (e.g. Puma's `after_stopped` DSL hook in single mode). `close` now detects this case and delegates to a background thread, joining it so the caller blocks until the producer is fully closed (#866).
5
28
 
data/Gemfile CHANGED
@@ -5,7 +5,7 @@ source "https://rubygems.org"
5
5
  gemspec
6
6
 
7
7
  # Relaxed from 2.7 because we support Ruby 3.1
8
- gem "zeitwerk", "~> 2.7.0"
8
+ gem "zeitwerk", "~> 2.8.0"
9
9
 
10
10
  group :development do
11
11
  gem "byebug"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- waterdrop (2.10.1)
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,15 +12,14 @@ 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.5)
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
- karafka-rdkafka (0.27.0)
22
+ karafka-rdkafka (0.27.2)
24
23
  ffi (~> 1.17.1)
25
24
  json (> 2.0)
26
25
  logger
@@ -39,14 +38,9 @@ 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
- zeitwerk (2.7.5)
43
+ zeitwerk (2.8.2)
50
44
 
51
45
  PLATFORMS
52
46
  ruby
@@ -60,18 +54,17 @@ DEPENDENCIES
60
54
  simplecov
61
55
  warning
62
56
  waterdrop!
63
- zeitwerk (~> 2.7.0)
57
+ zeitwerk (~> 2.8.0)
64
58
 
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.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
73
- karafka-core (2.5.13) sha256=0acec083043bb6166c4b647a7458091cc7b08066d3b92a026932925ec7e07f61
74
- karafka-rdkafka (0.27.0) sha256=6a74bae8bdf189af81ad5b2ed374d1ebbf9dc6c785f21e72a5f4c8211275b1ee
65
+ json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b
66
+ karafka-core (2.6.2) sha256=c2fd7f277201b8ca97b824b364ad76bf776b8f5527bc422dafb71f7ae48d3a13
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
77
70
  minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
@@ -81,12 +74,10 @@ 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.1)
89
- zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
79
+ waterdrop (2.10.3)
80
+ zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12
90
81
 
91
82
  BUNDLED WITH
92
83
  4.0.6
@@ -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
  #
@@ -113,11 +113,14 @@ module WaterDrop
113
113
  end
114
114
 
115
115
  # Shutdown the global connection pool
116
- def shutdown
116
+ #
117
+ # @param force [Boolean] when true, force-close each producer, purging unflushed messages.
118
+ # Defaults to false (graceful close) so in-flight messages are not silently dropped.
119
+ def shutdown(force: false)
117
120
  return unless @default_pool
118
121
 
119
122
  pool = @default_pool
120
- @default_pool.shutdown
123
+ @default_pool.shutdown(force: force)
121
124
  @default_pool = nil
122
125
 
123
126
  # Emit global event for pool shutdown
@@ -237,9 +240,16 @@ module WaterDrop
237
240
  end
238
241
 
239
242
  # Shutdown the connection pool
240
- def shutdown
243
+ #
244
+ # @param force [Boolean] when true, force-close each producer, purging any messages that do not
245
+ # flush within the producer's max wait timeout. Defaults to false: producers are closed
246
+ # gracefully so in-flight messages are flushed instead of being silently dropped when the
247
+ # broker is slow or unreachable.
248
+ def shutdown(force: false)
241
249
  @pool.shutdown do |producer|
242
- producer.close! if producer&.status&.active?
250
+ next unless producer&.status&.active?
251
+
252
+ force ? producer.close! : producer.close
243
253
  end
244
254
 
245
255
  # Emit event after pool is shut down
@@ -254,11 +264,13 @@ module WaterDrop
254
264
  # for API consistency across both individual producers and connection pools
255
265
  alias_method :close, :shutdown
256
266
 
257
- # Reload all connections in the pool
258
- # Useful for configuration changes or error recovery
267
+ # Reload all connections in the pool. Useful for configuration changes or error recovery
268
+ #
269
+ # @note Producers are always closed gracefully (never force-closed): a reload must not drop
270
+ # in-flight messages, so it waits for them to flush rather than purging the queue.
259
271
  def reload
260
272
  @pool.reload do |producer|
261
- producer.close! if producer&.status&.active?
273
+ producer.close if producer&.status&.active?
262
274
  end
263
275
 
264
276
  # Emit event after pool is reloaded
@@ -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
@@ -80,7 +80,6 @@ module WaterDrop
80
80
  end
81
81
  end
82
82
 
83
- # Alias so we can have a nicer API to abort transactions
84
- # This makes referencing easier
83
+ # Alias so we can have a nicer API to abort transactions. This makes referencing easier
85
84
  AbortTransaction = Errors::AbortTransaction
86
85
  end
@@ -60,7 +60,14 @@ module WaterDrop
60
60
  private
61
61
 
62
62
  # @param delivery_report [Rdkafka::Producer::DeliveryReport] delivery report
63
+ # @note This is the most frequently fired event in the system (once per delivered
64
+ # message) and most users do not subscribe to it. While the notifications bus
65
+ # short-circuits on empty listeners, that happens only after the payload hash is
66
+ # built, so we guard here to keep the no-listeners path allocation-free. We check on
67
+ # each emission to support late subscribers.
63
68
  def instrument_acknowledged(delivery_report)
69
+ return unless listening?
70
+
64
71
  @monitor.instrument(
65
72
  "message.acknowledged",
66
73
  caller: self,
@@ -111,6 +118,13 @@ module WaterDrop
111
118
  def build_error(delivery_report)
112
119
  ::Rdkafka::RdkafkaError.new(delivery_report.error)
113
120
  end
121
+
122
+ # Check if anyone is listening to the acknowledgement events
123
+ # @return [Boolean] true if there are any listeners
124
+ def listening?
125
+ listeners = @monitor.listeners["message.acknowledged"]
126
+ listeners && !listeners.empty?
127
+ end
114
128
  end
115
129
  end
116
130
  end
@@ -21,8 +21,7 @@ module WaterDrop
21
21
  # @note When there is a particular message produce error (not internal error), the error
22
22
  # is shipped via the delivery callback, not via error callback.
23
23
  def call(client_name, error)
24
- # Emit only errors related to our client
25
- # Same as with statistics (mor explanation there)
24
+ # Emit only errors related to our client, same as with statistics (mor explanation there)
26
25
  return unless @client_name == client_name
27
26
 
28
27
  @monitor.instrument(
@@ -47,7 +47,7 @@ module WaterDrop
47
47
  # @param event [Dry::Events::Event] event that happened with the details
48
48
  def on_messages_produced_async(event)
49
49
  messages = event[:messages]
50
- topics_count = messages.map { |message| "'#{message[:topic]}'" }.uniq.count
50
+ topics_count = messages.map { |message| message[:topic] }.uniq.count
51
51
 
52
52
  info(
53
53
  event,
@@ -62,7 +62,7 @@ module WaterDrop
62
62
  # @param event [Dry::Events::Event] event that happened with the details
63
63
  def on_messages_produced_sync(event)
64
64
  messages = event[:messages]
65
- topics_count = messages.map { |message| "'#{message[:topic]}'" }.uniq.count
65
+ topics_count = messages.map { |message| message[:topic] }.uniq.count
66
66
 
67
67
  info(event, "Sync producing of #{messages.size} messages to #{topics_count} topics")
68
68
 
@@ -218,8 +218,7 @@ module WaterDrop
218
218
  when :brokers
219
219
  statistics.fetch("brokers").each_value do |broker_statistics|
220
220
  # Skip bootstrap nodes
221
- # Bootstrap nodes have nodeid -1, other nodes have positive
222
- # node ids
221
+ # Bootstrap nodes have nodeid -1, other nodes have positive node ids
223
222
  next if broker_statistics["nodeid"] == -1
224
223
 
225
224
  public_send(
@@ -16,8 +16,7 @@ module WaterDrop
16
16
  extend ::Karafka::Core::Configurable
17
17
 
18
18
  # Ruby thread priority for the poller thread
19
- # Valid range: -3 to 3 (Ruby's thread priority range)
20
- # Higher values = higher priority
19
+ # Valid range: -3 to 3 (Ruby's thread priority range). Higher values = higher priority
21
20
  setting :thread_priority, default: 0
22
21
 
23
22
  # IO.select timeout in milliseconds
@@ -33,8 +33,7 @@ module WaterDrop
33
33
  end
34
34
  end
35
35
 
36
- # Waits until the latch is released
37
- # Returns immediately if already released
36
+ # Waits until the latch is released. Returns immediately if already released
38
37
  def wait
39
38
  @mutex.synchronize do
40
39
  @cv.wait(@mutex) until @released
@@ -186,8 +186,7 @@ module WaterDrop
186
186
  @ios_dirty = true
187
187
  end
188
188
 
189
- # Ensures the polling thread is running
190
- # Must be called within @mutex.synchronize
189
+ # Ensures the polling thread is running. Must be called within @mutex.synchronize
191
190
  def ensure_thread_running!
192
191
  return if @thread&.alive?
193
192
 
@@ -200,9 +199,29 @@ module WaterDrop
200
199
  # Main polling loop that runs in a dedicated thread
201
200
  def polling_loop
202
201
  backoff_ms = 0
202
+ clean_exit = false
203
203
 
204
204
  loop do
205
- break if @shutdown
205
+ # Decide whether to stop AND clear @thread in a single critical section. This is what
206
+ # closes the register/shutdown race: a concurrent `register` is serialized by @mutex, so
207
+ # it either runs before this block (we observe its producer plus `@shutdown = false` and
208
+ # keep polling) or after it (it finds `@thread` already nil and starts a fresh thread).
209
+ # Previously the exit decision and the `@thread = nil` teardown were separate and
210
+ # unsynchronized, so a producer registered in that gap was treated as already served by
211
+ # this exiting thread and then closed by its cleanup - left registered but never polled.
212
+ stop = @mutex.synchronize do
213
+ if @shutdown || @producers.empty?
214
+ @thread = nil
215
+ true
216
+ else
217
+ false
218
+ end
219
+ end
220
+
221
+ if stop
222
+ clean_exit = true
223
+ break
224
+ end
206
225
 
207
226
  # Apply backoff from previous error
208
227
  if backoff_ms > 0
@@ -213,9 +232,9 @@ module WaterDrop
213
232
  # Collect readable IOs (queue FDs)
214
233
  readable_ios, io_to_state = collect_readable_ios
215
234
 
216
- # Exit when no producers registered
217
- # New registrations will start a fresh thread via ensure_thread_running!
218
- break if readable_ios.empty?
235
+ # A producer may have registered right after the stop check above; if the cached snapshot
236
+ # is momentarily empty, loop to rebuild it instead of selecting on an empty set.
237
+ next if readable_ios.empty?
219
238
 
220
239
  poll_with_select(readable_ios, io_to_state)
221
240
  rescue => e
@@ -229,13 +248,12 @@ module WaterDrop
229
248
  end
230
249
  end
231
250
  ensure
232
- # Clear thread reference first so new registrations will start a fresh thread
233
- # This prevents race where register sees old thread as alive during cleanup
234
- @mutex.synchronize { @thread = nil }
235
-
236
- # When the poller thread exits (error or clean shutdown), close all remaining states
237
- # This releases any latches that might be waiting in unregister calls
238
- close_all_states
251
+ # A normal exit already cleared @thread above with an empty registry, so there is nothing to
252
+ # release - and skipping cleanup here is what keeps a producer registered in the exit gap
253
+ # from being closed: its fresh thread owns it now. Only an abnormal exit (an exception
254
+ # escaped the loop) can leave producers registered with callers blocked in `unregister`;
255
+ # release those so they don't hang.
256
+ close_all_states unless clean_exit
239
257
  end
240
258
 
241
259
  # Broadcasts an error to all registered producers' monitors
@@ -379,13 +397,15 @@ module WaterDrop
379
397
  state.close
380
398
  end
381
399
 
382
- # Closes all remaining producer states
383
- # Called when the poller thread exits to release any pending latches
384
- # This prevents deadlocks if producers are waiting in unregister
400
+ # Releases any producer states still registered when the poller thread exits ABNORMALLY (an
401
+ # exception escaped the loop), so callers blocked in `unregister` waiting on their latch are
402
+ # not left hanging. A normal exit clears the registry through the loop and never calls this,
403
+ # which is why no thread-ownership check is needed here.
385
404
  def close_all_states
386
405
  states = @mutex.synchronize do
387
- to_close = @producers.values.dup
388
- @producers.clear
406
+ @thread = nil
407
+ to_close = @producers.values
408
+ @producers = {}
389
409
  @ios_dirty = true
390
410
  to_close
391
411
  end
@@ -25,8 +25,7 @@ module WaterDrop
25
25
  client.enable_queue_io_events(@writer.fileno)
26
26
  end
27
27
 
28
- # Signals by writing a byte to the pipe
29
- # Used to wake IO.select for continue/close signals
28
+ # Signals by writing a byte to the pipe. Used to wake IO.select for continue/close signals
30
29
  # Thread-safe and non-blocking; silently ignores errors
31
30
  def signal
32
31
  @writer.write_nonblock("W", exception: false)
@@ -53,8 +53,7 @@ module WaterDrop
53
53
  @io = @queue_pipe.reader
54
54
  end
55
55
 
56
- # Drains the queue pipe
57
- # Called before polling to clear any pending signals
56
+ # Drains the queue pipe. Called before polling to clear any pending signals
58
57
  def drain
59
58
  @queue_pipe.drain
60
59
  end
@@ -88,8 +87,7 @@ module WaterDrop
88
87
 
89
88
  private_constant :STALE_CHECK_THROTTLE_MS
90
89
 
91
- # Marks this producer as having been polled
92
- # Called after polling to track staleness
90
+ # Marks this producer as having been polled. Called after polling to track staleness
93
91
  def mark_polled!
94
92
  @last_poll_time = monotonic_now
95
93
  end
@@ -21,7 +21,7 @@ module WaterDrop
21
21
  "message.produced_async",
22
22
  producer_id: id,
23
23
  message: message
24
- ) { produce(message) }
24
+ ) { produce(message, "produce_async") }
25
25
  rescue *SUPPORTED_FLOW_ERRORS => e
26
26
  # We use this syntax here because we want to preserve the original `#cause` when we
27
27
  # instrument the error and there is no way to manually assign `#cause` value
@@ -62,7 +62,7 @@ module WaterDrop
62
62
  ) do
63
63
  with_transaction_if_transactional do
64
64
  messages.each do |message|
65
- dispatched << produce(message)
65
+ dispatched << produce(message, "produce_many_async")
66
66
  end
67
67
  end
68
68
 
@@ -12,12 +12,15 @@ module WaterDrop
12
12
  def buffer(message)
13
13
  ensure_active!
14
14
 
15
+ # The append runs under @buffer_mutex because flush/purge/close swap @messages for a fresh
16
+ # array under the same lock. Without it, a concurrent swap between reading @messages and
17
+ # appending would land the message in the orphaned old array and silently lose it.
15
18
  @monitor.instrument(
16
19
  "message.buffered",
17
20
  producer_id: id,
18
21
  message: message,
19
22
  buffer: @messages
20
- ) { @messages << message }
23
+ ) { @buffer_mutex.synchronize { @messages << message } }
21
24
  end
22
25
 
23
26
  # Adds given messages into the internal producer buffer without flushing them to Kafka
@@ -29,13 +32,16 @@ module WaterDrop
29
32
  def buffer_many(messages)
30
33
  ensure_active!
31
34
 
35
+ # The concat runs under @buffer_mutex for the same reason as #buffer: flush/purge/close swap
36
+ # @messages under the lock, so an unguarded concat could append into an array that has just
37
+ # been captured for dispatch (or discarded), silently losing the messages.
32
38
  @monitor.instrument(
33
39
  "messages.buffered",
34
40
  producer_id: id,
35
41
  messages: messages,
36
42
  buffer: @messages
37
43
  ) do
38
- messages.each { |message| @messages << message }
44
+ @buffer_mutex.synchronize { @messages.concat(messages) }
39
45
  messages
40
46
  end
41
47
  end
@@ -83,6 +89,32 @@ module WaterDrop
83
89
  return data_for_dispatch if data_for_dispatch.empty?
84
90
 
85
91
  sync ? produce_many_sync(data_for_dispatch) : produce_many_async(data_for_dispatch)
92
+ rescue Errors::ProduceManyError => e
93
+ # A dispatch failed partway through the batch. Re-buffer the messages that never reached
94
+ # librdkafka so a partial failure does not silently drop valid buffered messages. For a
95
+ # transactional producer the whole batch is rolled back (nothing is visible to consumers),
96
+ # so all of it is restored; for a regular producer `e.dispatched` holds the handles already
97
+ # created, so only the remainder is restored.
98
+ requeue_unflushed(transactional? ? data_for_dispatch : data_for_dispatch.drop(e.dispatched.size))
99
+
100
+ raise
101
+ rescue Errors::MessageInvalidError
102
+ # Validation runs before anything is dispatched, so nothing reached librdkafka. Restore the
103
+ # whole batch instead of dropping valid messages alongside the invalid one.
104
+ requeue_unflushed(data_for_dispatch)
105
+
106
+ raise
107
+ end
108
+
109
+ # Puts not-yet-dispatched messages back at the front of the buffer (preserving their original
110
+ # order relative to each other and to anything buffered concurrently), so a failed flush does
111
+ # not lose them.
112
+ #
113
+ # @param messages [Array<Hash>] messages to restore to the buffer
114
+ def requeue_unflushed(messages)
115
+ return if messages.empty?
116
+
117
+ @buffer_mutex.synchronize { @messages.unshift(*messages) }
86
118
  end
87
119
  end
88
120
  end
@@ -57,6 +57,15 @@ module WaterDrop
57
57
  # @note After reload, the producer will automatically retry the failed operation
58
58
  def idempotent_reload_client_on_fatal_error(attempt, error)
59
59
  @operating_mutex.synchronize do
60
+ # When several threads share an idempotent producer, one fatal librdkafka condition fails
61
+ # all their in-flight produces at once and each enters this method. The mutex serializes
62
+ # them, but a thread that waited here may arrive after another has already reloaded -
63
+ # resetting @client to nil and moving the producer to the configured state. Running
64
+ # reload! again would call methods on a nil @client and raise NoMethodError, so we bail
65
+ # out and let #produce retry against the freshly reloaded client. This mirrors the
66
+ # `return if @status.configured?` guard on the transactional reload path.
67
+ next if @client.nil? || @status.configured?
68
+
60
69
  # Emit producer.reload event before reload
61
70
  # Users can subscribe to this event and modify event[:caller].config.kafka to change
62
71
  # producer config
@@ -17,6 +17,16 @@ module WaterDrop
17
17
 
18
18
  private_constant :LIFECYCLE
19
19
 
20
+ # States in which the producer is considered active and able to accept work. Kept as a single
21
+ # set so the current state can be classified in one atomic read (see `#active?` / `#to_sym`)
22
+ # rather than via a chain of predicate calls that could straddle a concurrent transition.
23
+ ACTIVE_STATES = %i[
24
+ connected
25
+ configured
26
+ disconnecting
27
+ disconnected
28
+ ].freeze
29
+
20
30
  # Creates a new instance of status with the initial state
21
31
  # @return [Status]
22
32
  def initialize
@@ -29,7 +39,10 @@ module WaterDrop
29
39
  # established or disconnected, meaning it was working but user disconnected for his own
30
40
  # reasons though sending could reconnect and continue.
31
41
  def active?
32
- connected? || configured? || disconnecting? || disconnected?
42
+ # Single read of @current so a concurrent transition cannot make this return false for a
43
+ # status that is in fact active (for example flipping configured -> connected mid-check
44
+ # while another thread reloads the client after a fatal error).
45
+ ACTIVE_STATES.include?(@current)
33
46
  end
34
47
 
35
48
  # @return [String] current status as a string
@@ -37,6 +50,13 @@ module WaterDrop
37
50
  @current.to_s
38
51
  end
39
52
 
53
+ # @return [Symbol] current lifecycle state captured as a single atomic read. Lets callers
54
+ # branch on one consistent value instead of issuing several predicate calls that could
55
+ # observe different states if the producer is transitioning on another thread.
56
+ def to_sym
57
+ @current
58
+ end
59
+
40
60
  LIFECYCLE.each do |state|
41
61
  # @example
42
62
  # def initial?
@@ -24,7 +24,7 @@ module WaterDrop
24
24
  producer_id: id,
25
25
  message: message
26
26
  ) do
27
- wait(produce(message))
27
+ wait(produce(message, "produce_sync"))
28
28
  end
29
29
  rescue *SUPPORTED_FLOW_ERRORS => e
30
30
  # We use this syntax here because we want to preserve the original `#cause` when we
@@ -84,21 +84,27 @@ module WaterDrop
84
84
  begin
85
85
  with_transaction_if_transactional do
86
86
  messages.each do |message|
87
- dispatched << produce(message)
87
+ dispatched << produce(message, "produce_many_sync")
88
88
  end
89
89
  end
90
90
  rescue *SUPPORTED_FLOW_ERRORS => e
91
91
  inline_error = e
92
92
  end
93
93
 
94
+ # Resolve the variant timeout once instead of re-resolving the fiber-local variant for
95
+ # every single handler we wait on
96
+ max_wait_timeout = current_variant.max_wait_timeout
97
+
94
98
  # This will ensure, that we have all verdicts before raising the failure, so we pass
95
99
  # all delivery handles having a final verdict
96
- dispatched.each { |handler| wait(handler, raise_response_error: false) }
100
+ dispatched.each do |handler|
101
+ wait(handler, max_wait_timeout: max_wait_timeout, raise_response_error: false)
102
+ end
97
103
 
98
104
  raise(inline_error) if inline_error
99
105
 
100
106
  # This will raise an error on the first error that have happened
101
- dispatched.each { |handler| wait(handler) }
107
+ dispatched.each { |handler| wait(handler, max_wait_timeout: max_wait_timeout) }
102
108
 
103
109
  dispatched
104
110
  end
@@ -8,6 +8,11 @@ module WaterDrop
8
8
  # in compacted topics. This module provides a dedicated API so users don't have to manually
9
9
  # construct `produce_*(topic:, key:, payload: nil, ...)` calls.
10
10
  module Tombstone
11
+ # Contract to validate that tombstone message input is correct
12
+ CONTRACT = Contracts::Tombstone.new
13
+
14
+ private_constant :CONTRACT
15
+
11
16
  # Produces a tombstone message to Kafka and waits for it to be delivered
12
17
  #
13
18
  # @param message [Hash] hash with at least `:topic`, `:key`, and `:partition` keys.
@@ -66,10 +71,9 @@ module WaterDrop
66
71
  # @raise [Errors::MessageInvalidError] when key or partition is missing
67
72
  def prepare_tombstone(message)
68
73
  message = message.dup
69
- message.delete(:payload)
70
74
  message[:payload] = nil
71
75
 
72
- Contracts::Tombstone.new.validate!(message, Errors::MessageInvalidError)
76
+ CONTRACT.validate!(message, Errors::MessageInvalidError)
73
77
 
74
78
  message
75
79
  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
@@ -34,7 +34,10 @@ module WaterDrop
34
34
  # When rdkafka-ruby detects empty hash, it will use the librdkafka defaults
35
35
  EMPTY_HASH = {}.freeze
36
36
 
37
- private_constant :EMPTY_HASH
37
+ # Contract to validate that variant alteration data is correct
38
+ CONTRACT = Contracts::Variant.new
39
+
40
+ private_constant :EMPTY_HASH, :CONTRACT
38
41
 
39
42
  attr_reader :max_wait_timeout, :topic_config, :producer
40
43
 
@@ -56,7 +59,7 @@ module WaterDrop
56
59
  @default = default
57
60
  super(producer)
58
61
 
59
- Contracts::Variant.new.validate!(to_h, Errors::VariantInvalidError)
62
+ CONTRACT.validate!(to_h, Errors::VariantInvalidError)
60
63
  end
61
64
 
62
65
  # @return [Boolean] is this a default variant for this producer
@@ -75,23 +78,34 @@ module WaterDrop
75
78
  Transactions
76
79
  ].each do |scope|
77
80
  scope.instance_methods(false).each do |method_name|
81
+ # We save and restore any variant already active for this producer in this fiber rather
82
+ # than unconditionally deleting it. A variant-wrapped method that yields user code (e.g.
83
+ # `transaction`) may wrap a nested same-producer variant call; without save/restore the
84
+ # inner call's `ensure` would clear the slot the outer scope still needs, so the rest of
85
+ # the outer scope would silently fall back to the default variant. When there was no outer
86
+ # entry we still `delete` (not nil-assign) to avoid leaving stale entries behind.
87
+ #
78
88
  # @example
79
89
  # def produce_async(*args, &block)
80
90
  # ref = Fiber.current.waterdrop_clients ||= {}
91
+ # had = ref.key?(@producer.id)
92
+ # prev = ref[@producer.id]
81
93
  # ref[@producer.id] = self
82
94
  #
83
95
  # @producer.produce_async(*args, &block)
84
96
  # ensure
85
- # ref.delete(@producer.id)
97
+ # had ? (ref[@producer.id] = prev) : ref.delete(@producer.id)
86
98
  # end
87
99
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
88
100
  def #{method_name}(*args, &block)
89
101
  ref = Fiber.current.waterdrop_clients ||= {}
102
+ had = ref.key?(@producer.id)
103
+ prev = ref[@producer.id]
90
104
  ref[@producer.id] = self
91
105
 
92
106
  @producer.#{method_name}(*args, &block)
93
107
  ensure
94
- ref.delete(@producer.id)
108
+ had ? (ref[@producer.id] = prev) : ref.delete(@producer.id)
95
109
  end
96
110
  RUBY
97
111
  end
@@ -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 = []
@@ -152,8 +153,7 @@ module WaterDrop
152
153
 
153
154
  # We should raise an error when trying to use a producer with client from a fork. Always.
154
155
  if @client
155
- # We need to reset the client, otherwise there might be attempt to close the parent
156
- # client
156
+ # We need to reset the client, otherwise there might be attempt to close the parent client
157
157
  @client = nil
158
158
  raise Errors::ProducerUsedInParentProcess, Process.pid
159
159
  end
@@ -264,6 +264,29 @@ module WaterDrop
264
264
  @middleware ||= config.middleware
265
265
  end
266
266
 
267
+ # Returns the variant currently in effect for dispatches on the current fiber.
268
+ #
269
+ # While executing inside a variant-wrapped call (any method invoked on the object returned by
270
+ # {#with} / {#variant}), this returns that variant; otherwise it returns the producer's default
271
+ # variant. It is primarily useful to middleware and instrumentation listeners that run
272
+ # synchronously within a dispatch and want to read the effective per-dispatch settings, such as
273
+ # `#topic_config`, `#max_wait_timeout` or `#default?`.
274
+ #
275
+ # @return [WaterDrop::Producer::Variant] the variant active for the current dispatch on this
276
+ # fiber, or the producer's default variant when not inside a variant-wrapped call
277
+ #
278
+ # @note The lookup is fiber-local and scoped to a single dispatch; it does not represent a
279
+ # producer-wide setting. Called from arbitrary code outside a variant-wrapped call it always
280
+ # returns the default variant. It is likewise not meaningful from asynchronous delivery
281
+ # callbacks (which run on the poller thread, a different fiber) - there it also returns the
282
+ # default variant, not the variant the acknowledged message was dispatched with.
283
+ def current_variant
284
+ # Read-only: the fiber-local hash is created by the variant wrapper methods only when needed,
285
+ # so we must not allocate it here just to look up a variant that may not exist.
286
+ clients = Fiber.current.waterdrop_clients
287
+ (clients && clients[id]) || @default_variant
288
+ end
289
+
267
290
  # Disconnects the producer from Kafka while keeping it configured for potential reconnection
268
291
  #
269
292
  # This method safely disconnects the underlying Kafka client while preserving the producer's
@@ -339,6 +362,19 @@ module WaterDrop
339
362
  # @param force [Boolean] should we force closing even with outstanding messages after the
340
363
  # max wait timeout
341
364
  def close(force: false)
365
+ # If the client was built in a different process, we have been forked. The client and its
366
+ # native resources belong to the parent, so we must never flush or close them here: with the
367
+ # real rdkafka client that is rd_kafka_destroy on a fork-inherited handle (undefined behavior),
368
+ # and it would also tear down a client the parent still uses. We just drop our references and
369
+ # the inherited finalizer and return. This matters most for the GC finalizer, which is
370
+ # inherited across fork and would otherwise run #close in the child at exit.
371
+ if @client && @pid != Process.pid
372
+ @client = nil
373
+ ObjectSpace.undefine_finalizer(id)
374
+
375
+ return
376
+ end
377
+
342
378
  # When closing from within the FD poller thread (e.g., from a callback like
343
379
  # message.acknowledged or error.occurred), we must delegate to a background thread.
344
380
  # Close performs flush which waits for delivery reports, but delivery reports require
@@ -382,7 +418,18 @@ module WaterDrop
382
418
 
383
419
  # Flush has its own buffer mutex but even if it is blocked, flushing can still happen
384
420
  # as we close the client after the flushing (even if blocked by the mutex)
385
- flush(true)
421
+ #
422
+ # This is best-effort: if a buffered message surfaces a terminal error here (for example
423
+ # a fatal error on an idempotent producer), we must still proceed to close the underlying
424
+ # client. Otherwise the native client and its resources would leak and the producer would
425
+ # stay stuck in the `:closing` state. The failure is already surfaced via the
426
+ # `error.occurred` instrumentation emitted by the dispatch itself, so swallowing the
427
+ # re-raised wrapper here does not hide it.
428
+ begin
429
+ flush(true)
430
+ rescue Errors::ProduceError
431
+ nil
432
+ end
386
433
 
387
434
  # We should not close the client in several threads the same time
388
435
  # It is safe to run it several times but not exactly the same moment
@@ -498,15 +545,21 @@ module WaterDrop
498
545
  # Ensures that we don't run any operations when the producer is not configured or when it
499
546
  # was already closed
500
547
  def ensure_active!
501
- return if @status.active?
502
- return if @status.closing? && @operating_mutex.owned?
548
+ # Capture the lifecycle state once. Another thread may be transitioning the producer between
549
+ # states (for example configured -> connected while reloading the client after a fatal error),
550
+ # and issuing several @status predicate calls here could otherwise observe an inconsistent mix
551
+ # of states and raise StatusInvalidError for what is in fact a valid, active producer.
552
+ state = @status.to_sym
503
553
 
504
- raise Errors::ProducerNotConfiguredError, id if @status.initial?
505
- raise Errors::ProducerClosedError, id if @status.closing?
506
- raise Errors::ProducerClosedError, id if @status.closed?
554
+ return if Status::ACTIVE_STATES.include?(state)
555
+ return if state == :closing && @operating_mutex.owned?
556
+
557
+ raise Errors::ProducerNotConfiguredError, id if state == :initial
558
+ raise Errors::ProducerClosedError, id if state == :closing
559
+ raise Errors::ProducerClosedError, id if state == :closed
507
560
 
508
561
  # This should never happen
509
- raise Errors::StatusInvalidError, [id, @status.to_s]
562
+ raise Errors::StatusInvalidError, [id, state.to_s]
510
563
  end
511
564
 
512
565
  # Ensures that the message we want to send out to Kafka is actually valid and that it can be
@@ -520,26 +573,48 @@ module WaterDrop
520
573
  # Waits on a given handler
521
574
  #
522
575
  # @param handler [Rdkafka::Producer::DeliveryHandle]
576
+ # @param max_wait_timeout [Integer] max wait timeout in ms. Resolved from the current variant
577
+ # by default but can be passed in by batch operations that wait on many handlers, so the
578
+ # variant is not re-resolved for each of them.
523
579
  # @param raise_response_error [Boolean] should we raise the response error after we receive the
524
580
  # final result and it is an error.
525
- def wait(handler, raise_response_error: true)
581
+ def wait(handler, max_wait_timeout: current_variant.max_wait_timeout, raise_response_error: true)
526
582
  handler.wait(
527
- max_wait_timeout_ms: current_variant.max_wait_timeout,
583
+ max_wait_timeout_ms: max_wait_timeout,
528
584
  raise_response_error: raise_response_error
529
585
  )
530
586
  end
531
587
 
532
- # @return [Producer::Variant] the variant config. Either custom if built using `#with` or
533
- # a default one.
534
- def current_variant
535
- Fiber.current.waterdrop_clients ||= {}
536
- Fiber.current.waterdrop_clients[id] || @default_variant
588
+ # Dispatches a message, ensuring transactional producers take the transaction lock before the
589
+ # operation is counted.
590
+ #
591
+ # For a transactional producer we wrap the whole dispatch (including the operations-counter
592
+ # bookkeeping) in `transaction`, so `@transaction_mutex` is acquired BEFORE
593
+ # `@operations_in_progress` is incremented. This makes `#produce` acquire locks in the same order
594
+ # as `#close` (`@transaction_mutex` -> `@operating_mutex` -> operations counter) and removes a
595
+ # lock-order inversion: without it, a dispatch that had already counted itself could block forever
596
+ # on `@transaction_mutex` held by a concurrent `#close` that was itself waiting for the operations
597
+ # counter to drain. When we already own the transaction lock (inside an explicit transaction block
598
+ # or the closing flush) the order is already correct, so we dispatch directly.
599
+ #
600
+ # @param message [Hash] message we want to send
601
+ # @param label [String] short name of the public dispatch method (e.g. `"produce_sync"`) that
602
+ # we surface in the `message.*` queue-full error type. Passed explicitly by each public entry
603
+ # point so we never have to walk the call stack to recover it (the number of internal frames
604
+ # varies because the transactional path wraps the dispatch in a `transaction`).
605
+ def produce(message, label)
606
+ if transactional? && !@transaction_mutex.owned?
607
+ transaction { produce_to_client(message, label) }
608
+ else
609
+ produce_to_client(message, label)
610
+ end
537
611
  end
538
612
 
539
613
  # Runs the client produce method with a given message
540
614
  #
541
615
  # @param message [Hash] message we want to send
542
- def produce(message)
616
+ # @param label [String] public dispatch method name used in the queue-full error type
617
+ def produce_to_client(message, label)
543
618
  produce_time ||= monotonic_now
544
619
 
545
620
  # This can happen only during flushing on closing, in case like this we don't have to
@@ -551,16 +626,20 @@ module WaterDrop
551
626
  ensure_active!
552
627
  end
553
628
 
629
+ # The variant is fiber-local and cannot change mid-call, so we resolve it once instead of
630
+ # paying the fiber-local lookup for each usage
631
+ variant = current_variant
632
+
554
633
  # We basically only duplicate the message hash only if it is needed.
555
634
  # It is needed when user is using a custom settings variant or when symbol is provided as
556
635
  # the topic name. We should never mutate user input message as it may be a hash that the
557
636
  # user is using for some other operations
558
- if message[:topic].is_a?(Symbol) || !current_variant.default?
637
+ if message[:topic].is_a?(Symbol) || !variant.default?
559
638
  message = message.dup
560
639
  # In case someone defines topic as a symbol, we need to convert it into a string as
561
640
  # librdkafka does not accept symbols
562
641
  message[:topic] = message[:topic].to_s
563
- message[:topic_config] = current_variant.topic_config
642
+ message[:topic_config] = variant.topic_config
564
643
  end
565
644
 
566
645
  result = if transactional?
@@ -569,13 +648,25 @@ module WaterDrop
569
648
  client.produce(**message)
570
649
  end
571
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
+
572
657
  # Reset attempts counter on successful produce
573
658
  @idempotent_fatal_error_attempts = 0
574
659
 
575
660
  result
576
661
  rescue SUPPORTED_FLOW_ERRORS.first => e
577
- # Check if this is a fatal error on an idempotent producer and we should reload
578
- if idempotent_reloadable?(e)
662
+ # Check if this is a fatal error on an idempotent producer and we should reload.
663
+ #
664
+ # We must never reload while closing. During `#close` the final `flush` runs while this
665
+ # thread already owns `@operating_mutex`; the idempotent reload re-acquires that same mutex,
666
+ # which Ruby rejects with `ThreadError: deadlock; recursive locking`, and it would also try to
667
+ # rebuild the very client we are tearing down. In that case we let the error propagate so
668
+ # `#close` can finish and release the underlying client.
669
+ if idempotent_reloadable?(e) && !@operating_mutex.owned?
579
670
  # Check if we've exceeded max reload attempts
580
671
  raise unless idempotent_retryable?
581
672
 
@@ -611,8 +702,6 @@ module WaterDrop
611
702
  # in an infinite loop, effectively hanging the processing
612
703
  raise unless monotonic_now - produce_time < @config.wait_timeout_on_queue_full
613
704
 
614
- label = caller_locations(2, 1)[0].label.split.last.split("#").last
615
-
616
705
  # We use this syntax here because we want to preserve the original `#cause` when we
617
706
  # instrument the error and there is no way to manually assign `#cause` value. We want to keep
618
707
  # the original cause to maintain the same API across all the errors dispatched to the
@@ -3,5 +3,5 @@
3
3
  # WaterDrop library
4
4
  module WaterDrop
5
5
  # Current WaterDrop version
6
- VERSION = "2.10.1"
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": {
@@ -312,9 +312,9 @@
312
312
  }
313
313
  },
314
314
  "node_modules/yaml": {
315
- "version": "2.8.4",
316
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
317
- "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
315
+ "version": "2.9.0",
316
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
317
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
318
318
  "dev": true,
319
319
  "license": "ISC",
320
320
  "bin": {
data/renovate.json CHANGED
@@ -17,7 +17,7 @@
17
17
  {
18
18
  "minimumReleaseAge": "7 days",
19
19
  "matchDepNames": [
20
- "/*/"
20
+ "*"
21
21
  ]
22
22
  },
23
23
  {
@@ -41,6 +41,13 @@
41
41
  ],
42
42
  "groupName": "ruby setup",
43
43
  "internalChecksFilter": "strict"
44
+ },
45
+ {
46
+ "description": "Let setup-ruby pass age gate before ruby so it is ready when the group PR is created",
47
+ "matchPackageNames": [
48
+ "ruby/setup-ruby"
49
+ ],
50
+ "minimumReleaseAge": "5 days"
44
51
  }
45
52
  ],
46
53
  "minimumReleaseAge": "7 days",
@@ -48,6 +55,9 @@
48
55
  "dependencies"
49
56
  ],
50
57
  "lockFileMaintenance": {
51
- "enabled": true
58
+ "enabled": true,
59
+ "schedule": [
60
+ "before 4am on the first day of the month"
61
+ ]
52
62
  }
53
63
  }
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.1
4
+ version: 2.10.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld