waterdrop 2.10.3 → 2.10.5

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: ec39a827b8ea9216ef39589a8ce2c8261ce29b5ba1386ddb513c2ca393b5ade1
4
- data.tar.gz: d37a7617f72d101f75c5d02f4c77627757ad5254322ecbf8bf1c30b0b502fb98
3
+ metadata.gz: d61c97700e2882107b4e3309176c22fc85d06df909e3f11f0779bb33cab0ec96
4
+ data.tar.gz: 1b4c0e3c7ac54d6b86905544396865934ee783ba730dc0ccc6b3bd6b954a7ce8
5
5
  SHA512:
6
- metadata.gz: e65469f5af00b6976f8d6bcc7efecbb8bc7ce3e84418928d6364197c2b84c600730bb36cfa0ffd2333ad7a726367406aea69fa56d9ecc796b1a31a8b5cb11f94
7
- data.tar.gz: 654c121690450afdbb356527cbfb142bacc0b7eddbd3dd0c69017d5564cbdeb0712647cd8b13c27180a7cc94a23cca4049d748935e0ca6ccef068f5f55c079d7
6
+ metadata.gz: 9d509ae8aafda829f217357e6d8447905910137a4af850cd8d4bac7a35bb3af703432339c05fa45117dd8acff2a481718f18a94dd7e7ea0ba0259a86b6ae8d42
7
+ data.tar.gz: 4a8c01eb9aa11c49eb810888a65a520731298ea500bdcaa2c0b78bc163ffc346cc3975f8cad06ea7e50c9c9d21357457eaf745269b35e598a9eccebaf611e7a4
data/.gitignore CHANGED
@@ -19,6 +19,8 @@ db/*.sqlite3
19
19
  *.rbc
20
20
  *.sassc
21
21
  .redcar/
22
+ .codex
23
+ .claude
22
24
  .capistrano/
23
25
  .sass-cache
24
26
  /config/god/sidekiq.rb
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.5
1
+ 4.0.7
data/.yard-lint.yml CHANGED
@@ -61,6 +61,34 @@ Documentation/BlankLineBeforeDefinition:
61
61
  SingleBlankLine: true
62
62
  OrphanedDocs: true
63
63
 
64
+ Documentation/DuplicateNamespaceComment:
65
+ Description: Detects namespaces documented with a YARD comment in more than one file.
66
+ Enabled: true
67
+ Severity: error
68
+
69
+ Documentation/UnderfilledLines:
70
+ Description: Detects documentation prose that wraps too early and wastes horizontal space.
71
+ Enabled: true
72
+ Severity: error
73
+ # Aligned with RuboCop's Layout/LineLength (Max: 100) so documentation prose
74
+ # wraps to the same width as code.
75
+ MaxLength: 100
76
+
77
+ Documentation/LineLength:
78
+ Description: Detects documentation lines that exceed the maximum length.
79
+ Enabled: true
80
+ Severity: error
81
+ # Aligned with RuboCop's Layout/LineLength (Max: 100).
82
+ MaxLength: 100
83
+
84
+ Documentation/TextSubstitution:
85
+ Description: Detects em/en-dashes in documentation and replaces them with hyphens.
86
+ Enabled: true
87
+ Severity: error
88
+ Substitutions:
89
+ "—": "-" # em-dash (U+2014)
90
+ "–": "-" # en-dash (U+2013)
91
+
64
92
  # Tags validators
65
93
  Tags/Order:
66
94
  Description: Enforces consistent ordering of YARD tags.
@@ -141,7 +169,7 @@ Tags/OptionTags:
141
169
  Tags/ExampleSyntax:
142
170
  Description: Validates Ruby syntax in @example tags.
143
171
  Enabled: true
144
- Severity: warning
172
+ Severity: error
145
173
 
146
174
  Tags/RedundantParamDescription:
147
175
  Description: Detects meaningless parameter descriptions that add no value.
data/CHANGELOG.md CHANGED
@@ -1,43 +1,54 @@
1
1
  # WaterDrop changelog
2
2
 
3
+ ## 2.10.5 (2026-09-23)
4
+ - [Fix] Stop `#flush` from running middleware twice on messages re-buffered after a failed flush (regression of #474 reintroduced by the failure/requeue path), which double-encrypted/compressed/serialized payloads and duplicated headers on retry.
5
+ - [Fix] Re-check the producer liveness under `@buffer_mutex` in `#buffer`/`#buffer_many`, so a producer closed between the liveness check and the append raises `ProducerClosedError` instead of accepting the message into a closed producer's buffer and losing it silently.
6
+ - [Fix] Restore both buffers when `#flush` fails before anything is dispatched (a middleware step raising, for example) instead of discarding the whole batch. Only `ProduceManyError` and `MessageInvalidError` were covered before.
7
+
8
+ ## 2.10.4 (2026-08-26)
9
+ - [Fix] Avoid a `FrozenError` on `Producer#close` when the producer is configured with a frozen string id (for example a frozen string literal, `config.id = "rspec"`).
10
+ - [Fix] Emit `connection_pool.shutdown` only once when shutting down the global connection pool.
11
+ - [Fix] Retry a produce that races a concurrent idempotent fatal-error reload instead of leaking a raw `Rdkafka::ClosedProducerError`/`Rdkafka::ClosedInnerError`.
12
+ - [Maintenance] Stop the `#partition_count when topic does not exist` spec from flaking on slow CI runners by waiting for authoritative broker metadata before asserting the count.
13
+
3
14
  ## 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`.
15
+ - [Feature] Add `wait_timeout_on_transaction_abort` (default `0`, disabled) - an opt-in mitigation for [librdkafka#4849](https://github.com/confluentinc/librdkafka/issues/4849) that waits (up to the given ms) for the first delivery to be acknowledged before aborting, avoiding a fatal `INVALID_TXN_STATE`. Off by default because it changes abort semantics: the awaited message is written to the log (aborted) instead of being purged.
5
16
 
6
17
  ## 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.
18
+ - [Feature] Expose `Producer#current_variant` as a public method returning the variant active for the current dispatch on the current fiber, so middleware and instrumentation listeners can read the effective per-dispatch settings (`topic_config`, `max_wait_timeout`, `default?`).
19
+ - [Enhancement] Stop allocating one interpolated string per message in `LoggerListener` batch produce handlers.
9
20
  - [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.
21
+ - [Enhancement] Skip building the `message.acknowledged` instrumentation payload in the delivery callback when nothing is subscribed to that event.
22
+ - [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.
23
+ - [Enhancement] Do not allocate the fiber-local variants hash on the `Producer#current_variant` read path when variants are never used.
24
+ - [Enhancement] Cache the variant validation contract in a constant instead of instantiating a new `Contracts::Variant` on every `Producer#with` / `Producer#variant` call.
25
+ - [Enhancement] Cache the tombstone validation contract in a constant instead of instantiating a new `Contracts::Tombstone` per tombstone message.
26
+ - [Enhancement] Replace explicit `Warning[:performance]` opt-in with a dynamic approach using `Warning.categories` to enable all stable opt-in warning categories in the test suite.
27
+ - [Fix] Prevent a deadlock between a transactional single-message dispatch and `#close` caused by an inverted lock order; transactional dispatches now take `@transaction_mutex` before the operation is counted.
28
+ - [Fix] Prevent a deadlock (`ThreadError: deadlock; recursive locking`) when closing an idempotent producer (with `reload_on_idempotent_fatal_error` enabled) whose final buffer flush surfaces a fatal librdkafka error; the idempotent reload is now skipped on the closing path.
29
+ - [Fix] Make concurrent idempotent fatal-error reload thread-safe so a second thread's reload can no longer raise `NoMethodError` after the first reset `@client`.
30
+ - [Fix] Stop `#flush_async` / `#flush_sync` from silently dropping valid buffered messages when the dispatch fails; unsent messages are now re-buffered so they can be retried instead of being lost.
31
+ - [Fix] Make `Producer#close` fork-safe so the GC finalizer inherited by a forked child can no longer flush and close the parent's client.
32
+ - [Fix] Guard the internal buffer appends in `Producer#buffer` and `Producer#buffer_many` with `@buffer_mutex` so a concurrent flush/purge/close swap can no longer drop a message into an orphaned array.
33
+ - [Fix] Stop a nested same-producer variant call from clobbering the outer variant inside a variant `transaction` block; the wrapper now saves and restores the previous fiber-local entry instead of deleting it.
34
+ - [Fix] Stop `ConnectionPool#shutdown` and `#reload` from silently dropping in-flight messages. They now close producers gracefully by default (`#reload` always; `#shutdown` unless called with the new `force: true`); pass `pool.shutdown(force: true)` to keep the old force-and-purge behavior.
35
+ - [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; the poller now decides to stop and clears its thread reference in a single mutex section.
25
36
 
26
37
  ## 2.10.1 (2026-05-25)
27
38
  - [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).
28
39
 
29
40
  ## 2.10.0 (2026-05-07)
30
- - [Fix] Clean up native rdkafka client, global instrumentation callbacks, and poller registration when `init_transactions` fails during producer client construction. Previously, each failed attempt permanently leaked native threads, pipe file descriptors, and callback registry entries because the started `rd_kafka_t` handle was abandoned without being destroyed.
31
- - **[Breaking]** Skip emitting librdkafka statistics when nothing is subscribed to `statistics.emitted` at the time the underlying rdkafka client is constructed. When no listener is present at build time, `statistics.interval.ms` is forced to `0` regardless of user configuration and the statistics callback is not registered, saving substantial allocations in the hot path (no JSON parsing, no statistics hash materialization, no decoration work). To use statistics, subscribe a listener to `statistics.emitted` BEFORE the first producer use (before the underlying client is lazily initialized).
32
- - **[Breaking]** Raise `WaterDrop::Errors::StatisticsNotEnabledError` when attempting to subscribe to `statistics.emitted` (either via block or via a listener that responds to `on_statistics_emitted`) on a monitor where librdkafka statistics have been disabled at client build time. This replaces the "silent nothing" failure mode with an immediate, actionable error that pinpoints the timing mistake.
41
+ - [Fix] Clean up native rdkafka client, global instrumentation callbacks, and poller registration when `init_transactions` fails during producer client construction, so failed attempts no longer leak native threads, file descriptors, and callback registry entries.
42
+ - **[Breaking]** Skip emitting librdkafka statistics when nothing is subscribed to `statistics.emitted` at the time the underlying rdkafka client is constructed (`statistics.interval.ms` is forced to `0`), saving substantial allocations in the hot path. To use statistics, subscribe a listener to `statistics.emitted` BEFORE the first producer use.
43
+ - **[Breaking]** Raise `WaterDrop::Errors::StatisticsNotEnabledError` when attempting to subscribe to `statistics.emitted` on a monitor where librdkafka statistics have been disabled at client build time, replacing the previous "silent nothing" failure mode.
33
44
  - [Feature] Add tombstone API (`#tombstone_sync`, `#tombstone_async`, `#tombstone_many_sync`, `#tombstone_many_async`) for producing tombstone records (nil-payload messages) with required key and partition validation. Works with variants.
34
- - [Fix] Add `ensure_same_process!` to `Poller#unregister` for fork safety. Without this, a child process that inherited a pre-fork producer would deadlock on `producer.close` because `unregister` waited on a latch the dead parent poller thread would never release.
45
+ - [Fix] Add `ensure_same_process!` to `Poller#unregister` for fork safety, so a child that inherited a pre-fork producer no longer deadlocks on `producer.close`.
35
46
 
36
47
  ## 2.9.0 (2026-04-08)
37
48
  - [Fix] Use `delete` in the variant ensure block to avoid leaving stale nil entries in `Fiber.current.waterdrop_clients` and prevent memory leaks in long-running processes (#836).
38
49
  - [Fix] Exclude test files, `.github/`, and `log/` directories from gem releases to reduce package size.
39
50
  - **[Breaking]** Switch default polling mode from `:thread` to `:fd`. If you experience any issues, you can revert to the previous behavior by setting `config.polling.mode = :thread`. The `:thread` mode will be deprecated in 2.10 and removed in 2.11.
40
- - **[Breaking]** Statistics decorator now only decorates keys used by the built-in Datadog metrics listener (`tx`, `txretries`, `txerrs`, `rxerrs`) and skips unused subtrees (`topics`, broker window stats). This reduces decoration cost by ~425x on large clusters (10 brokers, 20 topics, 2000 partitions). Users who rely on other `_d` or `_fd` keys in custom instrumentation should provide a custom decorator via `config.statistics_decorator`.
51
+ - **[Breaking]** Statistics decorator now only decorates keys used by the built-in Datadog metrics listener (`tx`, `txretries`, `txerrs`, `rxerrs`) and skips unused subtrees, greatly reducing decoration cost on large clusters. Users who rely on other `_d` or `_fd` keys in custom instrumentation should provide a custom decorator via `config.statistics_decorator`.
41
52
  - [Feature] Add `config.statistics_decorator` setting to allow full control over the `StatisticsDecorator` instance used for statistics decoration. Users can provide a custom decorator with different `only_keys` and `excluded_keys` to match their instrumentation needs.
42
53
  - [Change] Upscale default timeout values 3x closer to librdkafka defaults to prevent intermediate timeouts during node recovery (`message.timeout.ms`: 50s → 150s, `transaction.timeout.ms`: 55s → 165s, `max_wait_timeout`: 60s → 180s).
43
54
 
data/Gemfile.lint.lock CHANGED
@@ -58,11 +58,11 @@ GEM
58
58
  unicode-display_width (3.2.0)
59
59
  unicode-emoji (~> 4.1)
60
60
  unicode-emoji (4.2.0)
61
- yard (0.9.38)
62
- yard-lint (1.4.0)
61
+ yard (0.9.45)
62
+ yard-lint (1.10.1)
63
63
  yard (~> 0.9)
64
64
  zeitwerk (~> 2.6)
65
- zeitwerk (2.7.4)
65
+ zeitwerk (2.8.2)
66
66
 
67
67
  PLATFORMS
68
68
  ruby
@@ -79,6 +79,7 @@ DEPENDENCIES
79
79
 
80
80
  CHECKSUMS
81
81
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
82
+ bundler (4.0.17) sha256=214e21431b5665dd2f99df8a5511c6b151d7a72e8015c8b38f8b775b61cbb6c1
82
83
  json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
83
84
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
84
85
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
@@ -100,9 +101,9 @@ CHECKSUMS
100
101
  standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2
101
102
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
102
103
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
103
- yard (0.9.38) sha256=721fb82afb10532aa49860655f6cc2eaa7130889df291b052e1e6b268283010f
104
- yard-lint (1.4.0) sha256=7dd88fbb08fd77cb840bea899d58812817b36d92291b5693dd0eeb3af9f91f0f
105
- zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b
104
+ yard (0.9.45) sha256=52e211493f7cb8a3ebf7e104a25a1e73937a3103092545d34cb88fafebb3dc51
105
+ yard-lint (1.10.1) sha256=172ea1905304580bd515c093d82831ec4889e725100c49d5bf77e62101f49129
106
+ zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12
106
107
 
107
108
  BUNDLED WITH
108
- 2.6.9
109
+ 4.0.17
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- waterdrop (2.10.3)
4
+ waterdrop (2.10.5)
5
5
  karafka-core (>= 2.5.12, < 3.0.0)
6
6
  karafka-rdkafka (>= 0.24.0)
7
7
  zeitwerk (~> 2.3)
@@ -14,12 +14,12 @@ GEM
14
14
  connection_pool (3.0.2)
15
15
  drb (2.2.3)
16
16
  ffi (1.17.4)
17
- io-console (0.8.2)
18
- json (2.20.0)
19
- karafka-core (2.6.2)
17
+ io-console (0.9.2)
18
+ json (2.21.2)
19
+ karafka-core (2.6.3)
20
20
  karafka-rdkafka (>= 0.20.0)
21
21
  logger (>= 1.6.0)
22
- karafka-rdkafka (0.27.2)
22
+ karafka-rdkafka (0.28.0)
23
23
  ffi (~> 1.17.1)
24
24
  json (> 2.0)
25
25
  logger
@@ -35,12 +35,12 @@ GEM
35
35
  ostruct (0.6.3)
36
36
  prism (1.9.0)
37
37
  rake (13.4.2)
38
- reline (0.6.3)
38
+ reline (0.7.0)
39
39
  io-console (~> 0.5)
40
40
  ruby2_keywords (0.0.5)
41
- simplecov (1.0.0)
41
+ simplecov (1.3.0)
42
42
  warning (1.6.0)
43
- zeitwerk (2.8.2)
43
+ zeitwerk (2.8.3)
44
44
 
45
45
  PLATFORMS
46
46
  ruby
@@ -61,10 +61,10 @@ CHECKSUMS
61
61
  connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
62
62
  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
63
63
  ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
64
- io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
65
- json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b
66
- karafka-core (2.6.2) sha256=c2fd7f277201b8ca97b824b364ad76bf776b8f5527bc422dafb71f7ae48d3a13
67
- karafka-rdkafka (0.27.2) sha256=3ccce96306642be70bff8168e4e737fc10f2ffae20bc0ff0a43d88dbb7452d31
64
+ io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
65
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
66
+ karafka-core (2.6.3) sha256=83a783895d8f69e5fd4b7e77617f6a43c129bbb905c93e1babb9ff299470ba4a
67
+ karafka-rdkafka (0.28.0) sha256=fd845140c42341c5892215cac1e8adacda53b0461688f2e16379d82cb33eb006
68
68
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
69
69
  mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
70
70
  minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
@@ -72,12 +72,12 @@ CHECKSUMS
72
72
  ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
73
73
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
74
74
  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
75
- reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
75
+ reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
76
76
  ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
77
- simplecov (1.0.0) sha256=bdc50b41fa4a8b3c860da4bf5d61ba4704479c51d08a8f53cbd56ae50778dca8
77
+ simplecov (1.3.0) sha256=d9886307863c1ead47657dcbed869bfffd82318808b09014d8f0ff77dcfe7492
78
78
  warning (1.6.0) sha256=a49cdfae19fb77d19afff2efbe45f8ab759e9cd25b4e4ce2c79dbaf46bdb6c9e
79
- waterdrop (2.10.3)
80
- zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12
79
+ waterdrop (2.10.5)
80
+ zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5
81
81
 
82
82
  BUNDLED WITH
83
83
  4.0.6
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # WaterDrop
2
2
 
3
- [![Build Status](https://github.com/karafka/waterdrop/workflows/ci/badge.svg)](https://github.com/karafka/waterdrop/actions?query=workflow%3Aci)
4
- [![Gem Version](https://badge.fury.io/rb/waterdrop.svg)](http://badge.fury.io/rb/waterdrop)
3
+ [![Build Status](https://github.com/karafka/waterdrop/actions/workflows/ci.yml/badge.svg)](https://github.com/karafka/waterdrop/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/waterdrop.svg)](https://badge.fury.io/rb/waterdrop)
5
5
  [![Join the chat at https://slack.karafka.io](https://raw.githubusercontent.com/karafka/misc/master/slack.svg)](https://slack.karafka.io)
6
6
 
7
7
  WaterDrop is a standalone gem that sends messages to Kafka easily with an extra validation layer. It is a part of the [Karafka](https://github.com/karafka/karafka) ecosystem.
@@ -4,6 +4,11 @@
4
4
  # Only specified warnings are allowed, all others should trigger failure
5
5
 
6
6
  allowed_patterns=(
7
+ # Enabling share groups (KIP-932) requires "share" in group.coordinator.rebalance.protocols
8
+ # on cp-kafka 8.3.0 (Kafka 4.x). That mechanism is deprecated in favour of the share.version
9
+ # feature flag (Kafka 5.0), but is still functionally required to enable share groups in this
10
+ # version, so the broker logs this deprecation warning on every startup. Expected here.
11
+ "group.coordinator.rebalance.protocols. is deprecated"
7
12
  "Performing controller activation"
8
13
  "registered with feature metadata.version"
9
14
  "TOPIC_ALREADY_EXISTS"
@@ -49,6 +49,9 @@ services:
49
49
  KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
50
50
  KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
51
51
  KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
52
+ KAFKA_GROUP_COORDINATOR_REBALANCE_PROTOCOLS: 'classic,consumer,share'
53
+ KAFKA_SHARE_COORDINATOR_STATE_TOPIC_REPLICATION_FACTOR: 1
54
+ KAFKA_SHARE_COORDINATOR_STATE_TOPIC_MIN_ISR: 1
52
55
  # Required by Confluent image when SASL is enabled
53
56
  # Also allow HTTP URLs for JWKS endpoint (Kafka 3.0+ blocks non-HTTPS by default)
54
57
  KAFKA_OPTS: "-Djava.security.auth.login.config=/etc/kafka/kafka_server_jaas.conf -Dorg.apache.kafka.sasl.oauthbearer.allowed.urls=http://keycloak:8080/realms/kafka/protocol/openid-connect/certs"
@@ -21,6 +21,9 @@ services:
21
21
  KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
22
22
  KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
23
23
  KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
24
+ KAFKA_GROUP_COORDINATOR_REBALANCE_PROTOCOLS: 'classic,consumer,share'
25
+ KAFKA_SHARE_COORDINATOR_STATE_TOPIC_REPLICATION_FACTOR: 1
26
+ KAFKA_SHARE_COORDINATOR_STATE_TOPIC_MIN_ISR: 1
24
27
  KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true"
25
28
  KAFKA_AUTHORIZER_CLASS_NAME: org.apache.kafka.metadata.authorizer.StandardAuthorizer
26
29
  KAFKA_OPTS: ""
data/docker-compose.yml CHANGED
@@ -21,5 +21,8 @@ services:
21
21
  KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
22
22
  KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
23
23
  KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
24
+ KAFKA_GROUP_COORDINATOR_REBALANCE_PROTOCOLS: 'classic,consumer,share'
25
+ KAFKA_SHARE_COORDINATOR_STATE_TOPIC_REPLICATION_FACTOR: 1
26
+ KAFKA_SHARE_COORDINATOR_STATE_TOPIC_MIN_ISR: 1
24
27
  KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true"
25
28
  KAFKA_AUTHORIZER_CLASS_NAME: org.apache.kafka.metadata.authorizer.StandardAuthorizer
@@ -80,8 +80,7 @@ module WaterDrop
80
80
  @topics[topic]
81
81
  end
82
82
 
83
- # Clears internal buffer
84
- # Used in between specs so messages do not leak out
83
+ # Clears internal buffer. Used in between specs so messages do not leak out
85
84
  def reset
86
85
  @transaction_level = 0
87
86
  @transaction_active = false
@@ -51,7 +51,7 @@ module WaterDrop
51
51
  # saves a significant number of allocations on the Ruby side (no JSON parsing, no
52
52
  # statistics hash materialization, no decorator work). Any listener subscribed after
53
53
  # the client has been built will not receive `statistics.emitted` events because
54
- # librdkafka never emits them in the first place — to use statistics, subscribe a
54
+ # librdkafka never emits them in the first place - to use statistics, subscribe a
55
55
  # listener BEFORE the first producer use.
56
56
  #
57
57
  # When statistics end up disabled (either because the user explicitly set the interval
@@ -94,7 +94,7 @@ module WaterDrop
94
94
 
95
95
  # Registers the global callbacks (statistics, error, oauth refresh) for this producer
96
96
  # on the shared `Karafka::Core::Instrumentation` managers. The statistics callback is
97
- # only registered when librdkafka is actually going to emit statistics — otherwise it
97
+ # only registered when librdkafka is actually going to emit statistics - otherwise it
98
98
  # would never fire and would only waste memory and a manager slot.
99
99
  #
100
100
  # @param producer [WaterDrop::Producer]
@@ -36,7 +36,7 @@ module WaterDrop
36
36
  constructor: ->(id) { id || "waterdrop-#{SecureRandom.hex(6)}" }
37
37
  )
38
38
  # option [Instance] logger that we want to use
39
- # @note Due to how rdkafka works, this setting is global for all the producers
39
+ # Due to how rdkafka works, this setting is global for all the producers
40
40
  setting(
41
41
  :logger,
42
42
  default: false,
@@ -169,7 +169,7 @@ module WaterDrop
169
169
  # option [Class] class for usage when creating the underlying client used to dispatch messages
170
170
  setting :client_class, default: Clients::Rdkafka
171
171
  # rdkafka options
172
- # @see https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md
172
+ # See https://github.com/edenhill/librdkafka/blob/master/CONFIGURATION.md
173
173
  setting :kafka, default: {}
174
174
  # Middleware chain that can be expanded with useful middleware steps
175
175
  setting(
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # WaterDrop library
4
3
  module WaterDrop
5
4
  # Connection pool wrapper for WaterDrop producers using the proven connection_pool gem.
6
5
  #
@@ -119,15 +118,11 @@ module WaterDrop
119
118
  def shutdown(force: false)
120
119
  return unless @default_pool
121
120
 
122
- pool = @default_pool
121
+ # The instrumentation event is emitted by the instance-level #shutdown we delegate to
122
+ # (with `pool: self`, i.e. this same global pool). We deliberately do not emit it again
123
+ # here - doing so fired `connection_pool.shutdown` twice for a single global shutdown.
123
124
  @default_pool.shutdown(force: force)
124
125
  @default_pool = nil
125
-
126
- # Emit global event for pool shutdown
127
- WaterDrop.instrumentation.instrument(
128
- "connection_pool.shutdown",
129
- pool: pool
130
- )
131
126
  end
132
127
 
133
128
  # Alias for shutdown to align with producer API
@@ -2,9 +2,8 @@
2
2
 
3
3
  module WaterDrop
4
4
  module Instrumentation
5
- # WaterDrop instrumentation monitor that we use to publish events
6
- # By default uses our internal notifications bus but can be used with
7
- # `ActiveSupport::Notifications` as well
5
+ # WaterDrop instrumentation monitor that we use to publish events. By default uses our internal
6
+ # notifications bus but can be used with `ActiveSupport::Notifications` as well
8
7
  class Monitor < ::Karafka::Core::Monitoring::Monitor
9
8
  # Event name for librdkafka statistics emissions
10
9
  STATISTICS_EVENT = "statistics.emitted"
@@ -29,8 +28,8 @@ module WaterDrop
29
28
  # Marks this monitor as no longer accepting new subscriptions to `statistics.emitted`.
30
29
  # Called by the rdkafka client builder when it decides to leave librdkafka statistics
31
30
  # disabled (because no listener was present at build time). Any subsequent attempt to
32
- # subscribe to `statistics.emitted` — either via a block or via a listener object that
33
- # responds to `on_statistics_emitted` — will raise
31
+ # subscribe to `statistics.emitted` - either via a block or via a listener object that
32
+ # responds to `on_statistics_emitted` - will raise
34
33
  # `WaterDrop::Errors::StatisticsNotEnabledError` instead of silently doing nothing.
35
34
  def freeze_statistics_listeners!
36
35
  @statistics_listeners_frozen = true
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # WaterDrop main module
4
3
  module WaterDrop
5
- # Namespace for FD-based polling components
6
4
  module Polling
7
5
  # Configuration for the global FD poller singleton
8
6
  # These settings apply to all producers using FD polling mode
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WaterDrop
4
- # Namespace for FD-based polling components
5
- # Contains the global Poller singleton and State class for managing producer polling
6
4
  module Polling
7
5
  # Global poller singleton that manages a single polling thread for all FD-mode producers
8
6
  # This replaces librdkafka's native background polling threads with a single Ruby thread
@@ -272,7 +270,8 @@ module WaterDrop
272
270
 
273
271
  # Collects all IOs to monitor and builds a mapping from IO to State
274
272
  # Uses cached arrays when possible to avoid allocations in the hot path
275
- # @return [Array<Array<IO>, Hash{IO => State}, Array<State>>] tuple of ios, io-to-state map, states
273
+ # @return [Array<Array<IO>, Hash{IO => State}, Array<State>>] tuple of ios, io-to-state map,
274
+ # states
276
275
  def collect_readable_ios
277
276
  # Fast path: return cached result if not dirty (no mutex needed)
278
277
  # Safe because @cached_result is frozen and assigned atomically
@@ -345,9 +344,8 @@ module WaterDrop
345
344
  end
346
345
  end
347
346
 
348
- # Polls all registered producers
349
- # Called when IO.select times out to ensure periodic polling happens
350
- # This ensures OAuth token refresh and statistics callbacks fire for all producers
347
+ # Polls all registered producers. Called when IO.select times out to ensure periodic polling
348
+ # happens. This ensures OAuth token refresh and statistics callbacks fire for all producers
351
349
  def poll_all_producers
352
350
  @cached_states.each { |state| poll_producer(state) }
353
351
  end
@@ -26,7 +26,8 @@ module WaterDrop
26
26
  # @param client [Rdkafka::Producer] the rdkafka producer client
27
27
  # @param monitor [Object] the producer's monitor for error reporting
28
28
  # @param max_poll_time [Integer] max time in ms to poll per cycle
29
- # @param periodic_poll_interval [Integer] max time in ms before this producer needs periodic poll
29
+ # @param periodic_poll_interval [Integer] max time in ms before this producer needs periodic
30
+ # poll
30
31
  # @raise [StandardError] if queue pipe setup fails (FD mode requires this to work)
31
32
  def initialize(producer_id, client, monitor, max_poll_time, periodic_poll_interval)
32
33
  @producer_id = producer_id
@@ -106,9 +107,8 @@ module WaterDrop
106
107
  @last_stale_result = (now - @last_poll_time) >= @periodic_poll_interval
107
108
  end
108
109
 
109
- # Signals the poller to remove this producer
110
- # Called from any thread when the producer is being closed
111
- # Sets closing flag BEFORE signaling to ensure poller sees it
110
+ # Signals the poller to remove this producer. Called from any thread when the producer is
111
+ # being closed. Sets closing flag BEFORE signaling to ensure poller sees it
112
112
  def signal_close
113
113
  @closing = true
114
114
  @queue_pipe.signal
@@ -133,9 +133,8 @@ module WaterDrop
133
133
  @close_latch.release!
134
134
  end
135
135
 
136
- # Waits for this state to be closed
137
- # Used by unregister to ensure synchronous cleanup before returning
138
- # This matches the threaded polling behavior which drains without timeout
136
+ # Waits for this state to be closed. Used by unregister to ensure synchronous cleanup before
137
+ # returning. This matches the threaded polling behavior which drains without timeout
139
138
  def wait_for_close
140
139
  @close_latch.wait
141
140
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # WaterDrop main module
4
3
  module WaterDrop
5
4
  # Namespace for FD-based polling components
6
5
  # Contains the global Poller singleton and State class for managing producer polling
@@ -44,15 +44,18 @@ module WaterDrop
44
44
  #
45
45
  # @param messages [Array<Hash>] array with messages that comply with the
46
46
  # {Contracts::Message} contract
47
+ # @param run_middleware [Boolean] should the middleware chain be run on the messages. Set to
48
+ # false only when the messages have already been through middleware (for example when
49
+ # re-dispatching a re-buffered flush batch) so it is not applied twice.
47
50
  #
48
51
  # @return [Array<Rdkafka::Producer::DeliveryHandle>] deliveries handles
49
52
  #
50
53
  # @raise [Rdkafka::RdkafkaError] When adding the messages to rdkafka's queue failed
51
54
  # @raise [Errors::MessageInvalidError] When any of the provided messages details are invalid
52
55
  # and the message could not be sent to Kafka
53
- def produce_many_async(messages)
56
+ def produce_many_async(messages, run_middleware: true)
54
57
  dispatched = []
55
- messages = middleware.run_many(messages)
58
+ messages = middleware.run_many(messages) if run_middleware
56
59
  messages.each { |message| validate_message!(message) }
57
60
 
58
61
  @monitor.instrument(
@@ -15,12 +15,22 @@ module WaterDrop
15
15
  # The append runs under @buffer_mutex because flush/purge/close swap @messages for a fresh
16
16
  # array under the same lock. Without it, a concurrent swap between reading @messages and
17
17
  # appending would land the message in the orphaned old array and silently lose it.
18
+ #
19
+ # The liveness check is repeated under the same lock because `close` marks the producer
20
+ # `:closing` before its final flush acquires @buffer_mutex. Checking only above the lock
21
+ # would let a close complete in between, stranding the message in a closed producer's
22
+ # buffer while this call returned success.
18
23
  @monitor.instrument(
19
24
  "message.buffered",
20
25
  producer_id: id,
21
26
  message: message,
22
27
  buffer: @messages
23
- ) { @buffer_mutex.synchronize { @messages << message } }
28
+ ) do
29
+ @buffer_mutex.synchronize do
30
+ ensure_active!
31
+ @messages << message
32
+ end
33
+ end
24
34
  end
25
35
 
26
36
  # Adds given messages into the internal producer buffer without flushing them to Kafka
@@ -34,14 +44,20 @@ module WaterDrop
34
44
 
35
45
  # The concat runs under @buffer_mutex for the same reason as #buffer: flush/purge/close swap
36
46
  # @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.
47
+ # been captured for dispatch (or discarded), silently losing the messages. The liveness
48
+ # check is repeated under the lock for the same reason as in #buffer: a close that
49
+ # completes between the check above and the lock would otherwise strand the batch.
38
50
  @monitor.instrument(
39
51
  "messages.buffered",
40
52
  producer_id: id,
41
53
  messages: messages,
42
54
  buffer: @messages
43
55
  ) do
44
- @buffer_mutex.synchronize { @messages.concat(messages) }
56
+ @buffer_mutex.synchronize do
57
+ ensure_active!
58
+ @messages.concat(messages)
59
+ end
60
+
45
61
  messages
46
62
  end
47
63
  end
@@ -53,7 +69,7 @@ module WaterDrop
53
69
  @monitor.instrument(
54
70
  "buffer.flushed_async",
55
71
  producer_id: id,
56
- messages: @messages
72
+ messages: messages
57
73
  ) { flush(false) }
58
74
  end
59
75
 
@@ -64,7 +80,7 @@ module WaterDrop
64
80
  @monitor.instrument(
65
81
  "buffer.flushed_sync",
66
82
  producer_id: id,
67
- messages: @messages
83
+ messages: messages
68
84
  ) { flush(true) }
69
85
  end
70
86
 
@@ -78,17 +94,47 @@ module WaterDrop
78
94
  # @note We use this method underneath to provide a different instrumentation for sync and
79
95
  # async flushing within the public API
80
96
  def flush(sync)
81
- data_for_dispatch = nil
97
+ fresh = nil
98
+ requeued = nil
99
+ data_for_dispatch = []
82
100
 
83
101
  @buffer_mutex.synchronize do
84
- data_for_dispatch = @messages
102
+ requeued = @requeued
103
+ @requeued = []
104
+ fresh = @messages
85
105
  @messages = []
86
106
  end
87
107
 
108
+ # Middleware runs exactly once per message: on the fresh messages here, never again on the
109
+ # already-transformed ones coming back from a failed flush, which a second pass would
110
+ # corrupt.
111
+ #
112
+ # A middleware step raising here (a serializer, encryptor or schema lookup failing) would
113
+ # otherwise destroy both buffers, since they were emptied above and nothing has been
114
+ # dispatched yet to recover them from. Messages are run one by one so that on failure the
115
+ # ones whose chain completed are kept as returned and are not transformed again, while the
116
+ # failing one and those after it stay on the middleware path. The failing one is not
117
+ # requeued because that would dispatch it without the rest of its chain.
118
+ processed = []
119
+
120
+ begin
121
+ fresh.each { |message| processed << middleware.run(message) }
122
+ rescue
123
+ restore_undispatched(fresh.drop(processed.size), requeued.concat(processed))
124
+
125
+ raise
126
+ end
127
+
128
+ data_for_dispatch = requeued.concat(processed)
129
+
88
130
  # Do nothing if nothing to flush
89
131
  return data_for_dispatch if data_for_dispatch.empty?
90
132
 
91
- sync ? produce_many_sync(data_for_dispatch) : produce_many_async(data_for_dispatch)
133
+ if sync
134
+ produce_many_sync(data_for_dispatch, run_middleware: false)
135
+ else
136
+ produce_many_async(data_for_dispatch, run_middleware: false)
137
+ end
92
138
  rescue Errors::ProduceManyError => e
93
139
  # A dispatch failed partway through the batch. Re-buffer the messages that never reached
94
140
  # librdkafka so a partial failure does not silently drop valid buffered messages. For a
@@ -106,15 +152,27 @@ module WaterDrop
106
152
  raise
107
153
  end
108
154
 
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.
155
+ # Puts both sets back after a failure that happened before anything reached librdkafka. They
156
+ # go to different buffers because middleware has run on one of them and not the other, which
157
+ # is what keeps it applied exactly once per message.
158
+ #
159
+ # @param fresh [Array<Hash>] messages whose middleware chain has not completed
160
+ # @param requeued [Array<Hash>] already middleware-processed messages
161
+ def restore_undispatched(fresh, requeued)
162
+ @buffer_mutex.synchronize do
163
+ @messages.unshift(*fresh)
164
+ @requeued.unshift(*requeued)
165
+ end
166
+ end
167
+
168
+ # Puts not-yet-dispatched messages back at the front of the retry buffer (preserving their
169
+ # original order relative to each other), so a failed flush does not lose them.
112
170
  #
113
- # @param messages [Array<Hash>] messages to restore to the buffer
171
+ # @param messages [Array<Hash>] already middleware-processed messages to restore
114
172
  def requeue_unflushed(messages)
115
173
  return if messages.empty?
116
174
 
117
- @buffer_mutex.synchronize { @messages.unshift(*messages) }
175
+ @buffer_mutex.synchronize { @requeued.unshift(*messages) }
118
176
  end
119
177
  end
120
178
  end
@@ -48,6 +48,9 @@ module WaterDrop
48
48
  #
49
49
  # @param messages [Array<Hash>] array with messages that comply with the
50
50
  # {Contracts::Message} contract
51
+ # @param run_middleware [Boolean] should the middleware chain be run on the messages. Set to
52
+ # false only when the messages have already been through middleware (for example when
53
+ # re-dispatching a re-buffered flush batch) so it is not applied twice.
51
54
  #
52
55
  # @return [Array<Rdkafka::Producer::DeliveryHandle>] delivery handles for messages that were
53
56
  # sent (can be used to verify offset, partition, etc via `#create_result`)
@@ -63,8 +66,8 @@ module WaterDrop
63
66
  # errors), returning handles guarantees the same type in the `dispatched` collection,
64
67
  # allowing uniform error handling. Each handle is in its final state after this method
65
68
  # returns, so you can call `handle.create_result` to obtain the delivery report if needed.
66
- def produce_many_sync(messages)
67
- messages = middleware.run_many(messages)
69
+ def produce_many_sync(messages, run_middleware: true)
70
+ messages = middleware.run_many(messages) if run_middleware
68
71
  messages.each { |message| validate_message!(message) }
69
72
 
70
73
  dispatched = []
@@ -16,7 +16,7 @@ module WaterDrop
16
16
  # Produces a tombstone message to Kafka and waits for it to be delivered
17
17
  #
18
18
  # @param message [Hash] hash with at least `:topic`, `:key`, and `:partition` keys.
19
- # `:payload` is not accepted — it will be silently removed if present.
19
+ # `:payload` is not accepted - it will be silently removed if present.
20
20
  #
21
21
  # @return [Rdkafka::Producer::DeliveryReport] delivery report
22
22
  #
@@ -28,7 +28,7 @@ module WaterDrop
28
28
  # Produces a tombstone message to Kafka and does not wait for results
29
29
  #
30
30
  # @param message [Hash] hash with at least `:topic`, `:key`, and `:partition` keys.
31
- # `:payload` is not accepted — it will be silently removed if present.
31
+ # `:payload` is not accepted - it will be silently removed if present.
32
32
  #
33
33
  # @return [Rdkafka::Producer::DeliveryHandle] delivery handle
34
34
  #
@@ -282,10 +282,10 @@ module WaterDrop
282
282
  #
283
283
  # librdkafka only marks a transaction as ongoing at the coordinator once the
284
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
285
+ # has merely been **sent** (it gates on `txn_req_cnt`, bumped on send). Aborting with the
286
+ # first produce still in flight can therefore reach a coordinator that does not yet consider
287
+ # the transaction started, which fails the abort with a fatal `INVALID_TXN_STATE`. See
288
+ # https://github.com/confluentinc/librdkafka/issues/4849
289
289
  #
290
290
  # A delivered message proves its partition completed registration, and that alone puts the
291
291
  # transaction in an `ongoing` state at the coordinator - so a single acknowledged delivery is
@@ -122,7 +122,7 @@ module WaterDrop
122
122
  topic_config: topic_config,
123
123
  # We pass this to validation, to make sure no-one alters the `acks` value when operating
124
124
  # in the transactional mode as it causes librdkafka to crash ruby
125
- # @see https://github.com/confluentinc/librdkafka/issues/4710
125
+ # See https://github.com/confluentinc/librdkafka/issues/4710
126
126
  transactional: @producer.transactional?,
127
127
  # We pass this for a similar reason as above
128
128
  idempotent: @producer.idempotent?
@@ -39,8 +39,6 @@ module WaterDrop
39
39
  attr_reader :id
40
40
  # @return [Status] producer status object
41
41
  attr_reader :status
42
- # @return [Array] internal messages buffer
43
- attr_reader :messages
44
42
  # @return [Object] monitor we want to use
45
43
  attr_reader :monitor
46
44
  # @return [Object] dry-configurable config object
@@ -71,6 +69,9 @@ module WaterDrop
71
69
 
72
70
  @status = Status.new
73
71
  @messages = []
72
+ # Messages re-buffered after a failed flush. Kept apart from @messages because they have
73
+ # already passed through middleware and must not be transformed again on the next flush.
74
+ @requeued = []
74
75
 
75
76
  # Instrument producer creation for global listeners
76
77
  class_monitor.instrument(
@@ -93,7 +94,9 @@ module WaterDrop
93
94
  .setup(...)
94
95
  .config
95
96
 
96
- @id = @config.id
97
+ # dup so a frozen id (e.g. a frozen string literal) isn't used as the finalizer target, since
98
+ # ObjectSpace.(un)define_finalizer mutates the object and would raise FrozenError on #close
99
+ @id = @config.id.to_s.dup
97
100
  @monitor = @config.monitor
98
101
  @contract = Contracts::Message.new(max_payload_size: @config.max_payload_size)
99
102
  @default_variant = Variant.new(self, default: true)
@@ -175,6 +178,12 @@ module WaterDrop
175
178
  @client
176
179
  end
177
180
 
181
+ # @return [Array] internal messages buffer awaiting a flush. Includes both freshly buffered
182
+ # messages and any messages re-buffered after a failed flush that are waiting to be retried.
183
+ def messages
184
+ @requeued + @messages
185
+ end
186
+
178
187
  # Returns the number of messages in the librdkafka producer queue.
179
188
  #
180
189
  # This count includes:
@@ -230,6 +239,7 @@ module WaterDrop
230
239
  @monitor.instrument("buffer.purged", producer_id: id) do
231
240
  @buffer_mutex.synchronize do
232
241
  @messages = []
242
+ @requeued = []
233
243
  end
234
244
 
235
245
  # We should not purge if there is no client initialized
@@ -319,6 +329,7 @@ module WaterDrop
319
329
  return false unless @client
320
330
  return false unless @status.connected?
321
331
  return false unless @messages.empty?
332
+ return false unless @requeued.empty?
322
333
  return false unless @operations_in_progress.value.zero?
323
334
 
324
335
  @status.disconnecting!
@@ -352,6 +363,7 @@ module WaterDrop
352
363
  return false unless @client
353
364
  return false unless @status.connected?
354
365
  return false unless @messages.empty?
366
+ return false unless @requeued.empty?
355
367
  return false if @transaction_mutex.locked?
356
368
  return false if @operating_mutex.locked?
357
369
 
@@ -501,7 +513,7 @@ module WaterDrop
501
513
  # Try to get buffer info safely
502
514
  if @buffer_mutex.try_lock
503
515
  begin
504
- parts << "buffer_size=#{@messages.size}"
516
+ parts << "buffer_size=#{@messages.size + @requeued.size}"
505
517
  ensure
506
518
  @buffer_mutex.unlock
507
519
  end
@@ -590,12 +602,13 @@ module WaterDrop
590
602
  #
591
603
  # For a transactional producer we wrap the whole dispatch (including the operations-counter
592
604
  # 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.
605
+ # `@operations_in_progress` is incremented. This makes `#produce` acquire locks in the same
606
+ # order as `#close` (`@transaction_mutex` -> `@operating_mutex` -> operations counter) and
607
+ # removes a lock-order inversion: without it, a dispatch that had already counted itself could
608
+ # block forever on `@transaction_mutex` held by a concurrent `#close` that was itself waiting
609
+ # for the operations counter to drain. When we already own the transaction lock (inside an
610
+ # explicit transaction block or the closing flush) the order is already correct, so we dispatch
611
+ # directly.
599
612
  #
600
613
  # @param message [Hash] message we want to send
601
614
  # @param label [String] short name of the public dispatch method (e.g. `"produce_sync"`) that
@@ -658,6 +671,29 @@ module WaterDrop
658
671
  @idempotent_fatal_error_attempts = 0
659
672
 
660
673
  result
674
+ rescue Rdkafka::ClosedProducerError, Rdkafka::ClosedInnerError
675
+ # A concurrent idempotent fatal-error reload closed the underlying client while this produce
676
+ # was already in flight. Unlike `#close`/`#disconnect`, which drain `@operations_in_progress`
677
+ # before closing the client, the idempotent reload swaps `@client` out from under sibling
678
+ # threads that have already passed the `@operating_mutex` gate and are inside `client.produce`.
679
+ # Racing with `@client.close`, such a thread sees either a producer already flagged closed
680
+ # (`ClosedProducerError`) or a nil inner librdkafka handle (`ClosedInnerError`) - which one
681
+ # depends purely on how far `close` has progressed.
682
+ #
683
+ # This is a benign, recoverable transient rather than a produce failure: the client has just
684
+ # been (or is being) rebuilt, so we retry the dispatch against the fresh client instead of
685
+ # surfacing a raw "closed producer" error to the caller - which would defeat the whole point
686
+ # of the transparent reload. Only the idempotent reload path closes the client with produces
687
+ # in flight, so we scope the retry to that configuration; anywhere else a closed client is a
688
+ # genuine error and must propagate. The retried pass re-runs `ensure_active!`, which raises
689
+ # `ProducerClosedError` once the producer is genuinely closing/closed, so this cannot spin
690
+ # forever.
691
+ raise unless config.reload_on_idempotent_fatal_error
692
+ raise if transactional?
693
+
694
+ @operations_in_progress.decrement
695
+
696
+ retry
661
697
  rescue SUPPORTED_FLOW_ERRORS.first => e
662
698
  # Check if this is a fatal error on an idempotent producer and we should reload.
663
699
  #
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # WaterDrop library
4
3
  module WaterDrop
5
4
  # Current WaterDrop version
6
- VERSION = "2.10.3"
5
+ VERSION = "2.10.5"
7
6
  end
data/lib/waterdrop.rb CHANGED
@@ -10,7 +10,6 @@ require "singleton"
10
10
  require "karafka-core"
11
11
  require "pathname"
12
12
 
13
- # WaterDrop library
14
13
  module WaterDrop
15
14
  class << self
16
15
  # @return [String] root path of this gem
data/package-lock.json CHANGED
@@ -286,9 +286,9 @@
286
286
  }
287
287
  },
288
288
  "node_modules/smol-toml": {
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==",
289
+ "version": "1.8.0",
290
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
291
+ "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==",
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.3
4
+ version: 2.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld
@@ -160,7 +160,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
160
160
  - !ruby/object:Gem::Version
161
161
  version: '0'
162
162
  requirements: []
163
- rubygems_version: 4.0.10
163
+ rubygems_version: 4.0.20
164
164
  specification_version: 4
165
165
  summary: Kafka messaging made easy!
166
166
  test_files: []