waterdrop 2.10.4 → 2.10.6

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: 32f1c3b166f74db06dc75080800f4972207facc943d00d7eeab73a6c33668aed
4
- data.tar.gz: 8de1c8eecf566e241bc375fea7de5f568cd055be35a83effcf5de23b7c92f468
3
+ metadata.gz: df6f8bc25170845840699e442886688586d193d69ddb89849bfbd0a6da2c7dbd
4
+ data.tar.gz: 15cd50dfc2d196580d5afb44aef4ecab2683b7ce11f966d06041c4f5f120cbfc
5
5
  SHA512:
6
- metadata.gz: 901c6f6a576c38807eee47641675b12f9ef3907325fb2435c6b2e8656724c4f57be6593a10b7b85348b8cadd317216fe84ba182a9ccfaf3fa0208119c0609686
7
- data.tar.gz: 25720c9a6ac31520400f181af47b389eae9def35f384cf9b1d89f8466c4cafc904c0390fbe14a1222c6917b3b26fbcc38d9580e9a0fa45db07d1cc9528c3bb85
6
+ metadata.gz: 6a0dd37309d89f9ffa8d3932f338edf56f059bbd620befc1c008c6375c258f515e4aec054370815fc56411ba787f887ca9bf7bc5173ec7cf6117f6db44cec7ce
7
+ data.tar.gz: 87e75e5ec48f647bc070603d49848a9d5032ab89fb411ac4e33683311b70736e287de5d6cdfebb4c1a77c96d7f09438e9c85c597faea7673031e11ff64f25bfa
data/.rubocop.yml CHANGED
@@ -12,7 +12,7 @@ inherit_gem:
12
12
 
13
13
  AllCops:
14
14
  NewCops: enable
15
- TargetRubyVersion: 3.2
15
+ TargetRubyVersion: 3.3
16
16
  Include:
17
17
  - "**/*.rb"
18
18
  - "**/*.gemspec"
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.6
1
+ 4.0.7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # WaterDrop changelog
2
2
 
3
+ ## 2.10.6 (2026-10-12)
4
+ - **[EOL]** Drop Ruby 3.2 support; require Ruby 3.3+.
5
+ - [Maintenance] Document that a middleware chain run is not atomic, so an in-place step followed by a raising step is applied again on the next `#flush`.
6
+ - [Maintenance] Stop the sync delivery error spec from flaking when librdkafka rejects the produce to the invalid topic inline instead of through the delivery report.
7
+
8
+ ## 2.10.5 (2026-09-23)
9
+ - [Fix] Stop `#flush` from running middleware twice on messages re-buffered after a failed flush, which double-processed payloads and duplicated headers on retry (regression of #474).
10
+ - [Fix] Raise `ProducerClosedError` instead of silently losing a message buffered while the producer is closing.
11
+ - [Fix] Restore both buffers when `#flush` fails before anything is dispatched, instead of discarding the whole batch.
12
+
3
13
  ## 2.10.4 (2026-08-26)
4
14
  - [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"`).
5
15
  - [Fix] Emit `connection_pool.shutdown` only once when shutting down the global connection pool.
@@ -7,10 +17,10 @@
7
17
  - [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.
8
18
 
9
19
  ## 2.10.3 (2026-07-15)
10
- - [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.
20
+ - [Feature] Add `wait_timeout_on_transaction_abort` (disabled by default) to avoid a fatal `INVALID_TXN_STATE` on abort ([librdkafka#4849](https://github.com/confluentinc/librdkafka/issues/4849)). When enabled, the awaited message is delivered as aborted instead of purged.
11
21
 
12
22
  ## 2.10.2 (2026-06-15)
13
- - [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?`).
23
+ - [Feature] Expose `Producer#current_variant`, returning the variant active for the current dispatch, for use in middleware and instrumentation listeners.
14
24
  - [Enhancement] Stop allocating one interpolated string per message in `LoggerListener` batch produce handlers.
15
25
  - [Enhancement] Use `Array#concat` in `Producer#buffer_many` instead of appending messages one by one.
16
26
  - [Enhancement] Skip building the `message.acknowledged` instrumentation payload in the delivery callback when nothing is subscribed to that event.
@@ -19,22 +29,22 @@
19
29
  - [Enhancement] Cache the variant validation contract in a constant instead of instantiating a new `Contracts::Variant` on every `Producer#with` / `Producer#variant` call.
20
30
  - [Enhancement] Cache the tombstone validation contract in a constant instead of instantiating a new `Contracts::Tombstone` per tombstone message.
21
31
  - [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.
22
- - [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.
23
- - [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.
32
+ - [Fix] Prevent a deadlock between a transactional single-message dispatch and `#close`.
33
+ - [Fix] Prevent a deadlock when closing an idempotent producer with `reload_on_idempotent_fatal_error` enabled whose final flush hits a fatal error.
24
34
  - [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`.
25
35
  - [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.
26
36
  - [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.
27
- - [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.
28
- - [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.
29
- - [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.
30
- - [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.
37
+ - [Fix] Prevent a concurrent flush, purge or close from dropping a message buffered with `#buffer` or `#buffer_many`.
38
+ - [Fix] Stop a nested variant call on the same producer from overriding the outer variant inside a variant `transaction` block.
39
+ - [Fix] Stop `ConnectionPool#shutdown` and `#reload` from dropping in-flight messages by closing producers gracefully. Use `pool.shutdown(force: true)` for the previous force-and-purge behavior.
40
+ - [Fix] Fix a race that could leave a newly registered producer permanently unpolled by the FD poller.
31
41
 
32
42
  ## 2.10.1 (2026-05-25)
33
- - [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).
43
+ - [Fix] Prevent `Producer#close` from raising `ThreadError: can't be called from trap context` when called from a signal trap (e.g. Puma's `after_stopped` hook in single mode). It now closes from a background thread and waits for it (#866).
34
44
 
35
45
  ## 2.10.0 (2026-05-07)
36
- - [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.
37
- - **[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.
46
+ - [Fix] Clean up the native client, instrumentation callbacks and poller registration when `init_transactions` fails during producer construction, so failed attempts no longer leak threads and file descriptors.
47
+ - **[Breaking]** Skip librdkafka statistics when nothing subscribes to `statistics.emitted` before the rdkafka client is built, to save allocations. To use statistics, subscribe a listener BEFORE the first producer use.
38
48
  - **[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.
39
49
  - [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.
40
50
  - [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`.
@@ -43,14 +53,14 @@
43
53
  - [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).
44
54
  - [Fix] Exclude test files, `.github/`, and `log/` directories from gem releases to reduce package size.
45
55
  - **[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.
46
- - **[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`.
56
+ - **[Breaking]** Decorate only the statistics keys used by the built-in Datadog listener (`tx`, `txretries`, `txerrs`, `rxerrs`). If you use other `_d` or `_fd` keys, set a custom `config.statistics_decorator`.
47
57
  - [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.
48
58
  - [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).
49
59
 
50
60
  **Upgrade Notes**: https://karafka.io/docs/Upgrades-WaterDrop-2.9/
51
61
 
52
62
  ## 2.8.16 (2026-02-25)
53
- - [Feature] Add FD-based polling mode (`config.polling.mode = :fd`) as an alternative to the default thread-based polling. FD mode uses a single Ruby thread with IO.select for efficient multiplexing, providing 39-54% higher throughput, lower memory usage, and fewer threads compared to the default thread mode.
63
+ - [Feature] Add FD-based polling (`config.polling.mode = :fd`): a single Ruby thread with `IO.select` instead of the default thread mode, with 39-54% higher throughput, lower memory usage and fewer threads.
54
64
  - [Feature] Add `config.polling.fd.max_time` setting (default: 100ms) to control maximum polling time per producer per cycle. This enables per-producer priority differentiation.
55
65
  - [Feature] Add `Producer#queue_size` (alias: `#queue_length`) to return the count of messages pending in the librdkafka queue. This counts messages that have been dispatched to librdkafka but not yet transmitted to the Kafka broker.
56
66
  - [Enhancement] Add `poll_nb` and `poll_drain` methods to rdkafka Producer for efficient non-blocking polling without GVL release overhead.
@@ -77,11 +87,7 @@
77
87
 
78
88
  ## 2.8.12 (2025-10-10)
79
89
  - [Enhancement] Introduce `reload_on_idempotent_fatal_error` to automatically reload librdkafka producer after fatal errors on idempotent (non-transactional) producers.
80
- - [Enhancement] Add configurable backoff and retry limits for fatal error recovery to prevent infinite reload loops:
81
- - `wait_backoff_on_idempotent_fatal_error` (default: 5000ms) - backoff before retrying after idempotent fatal error reload
82
- - `max_attempts_on_idempotent_fatal_error` (default: 5) - max reload attempts for idempotent fatal errors
83
- - `wait_backoff_on_transaction_fatal_error` (default: 1000ms) - backoff after transactional fatal error reload
84
- - `max_attempts_on_transaction_fatal_error` (default: 10) - max reload attempts for transactional fatal errors
90
+ - [Enhancement] Add backoff and retry limits for fatal error reloads to prevent infinite reload loops (defaults in brackets): `wait_backoff_on_idempotent_fatal_error` (5000ms), `max_attempts_on_idempotent_fatal_error` (5), `wait_backoff_on_transaction_fatal_error` (1000ms) and `max_attempts_on_transaction_fatal_error` (10).
85
91
  - [Enhancement] Ensure `error.occurred` is instrumented before idempotent fatal error reload for visibility.
86
92
  - [Enhancement] Automatically reset fatal error reload attempts counter on successful produce/transaction to allow recovery.
87
93
  - [Refactor] Extract idempotence-related logic into separate `WaterDrop::Producer::Idempotence` module.
@@ -107,7 +113,7 @@
107
113
 
108
114
  ## 2.8.8 (2025-09-23)
109
115
  - [Feature] Add `WaterDrop::ConnectionPool` for efficient connection pooling using the proven `connection_pool` gem.
110
- - [Feature] Add `WaterDrop.instrumentation` class-level instrumentation for producer lifecycle events. This allows external libraries to subscribe to `producer.created` and `producer.configured` events without needing producer instance references, enabling middleware injection and configuration by libraries like Datadog tracing.
116
+ - [Feature] Add class-level `WaterDrop.instrumentation` with `producer.created` and `producer.configured` events, so libraries (e.g. Datadog tracing) can configure producers without instance references.
111
117
  - **[EOL]** Remove Ruby `3.1` specs according to the EOL schedule.
112
118
 
113
119
  ## 2.8.7 (2025-09-02)
@@ -196,7 +202,7 @@ This release contains **BREAKING** changes. Make sure to read and apply upgrade
196
202
  - **[Breaking]** Remove no longer needed `wait_timeout` configuration option.
197
203
  - **[Breaking]** Do **not** validate or morph (via middleware) messages added to the buffer prior to `flush_sync` or `flush_async`.
198
204
  - [Enhancement] Provide `WaterDrop::Producer#transaction?` that returns only when producer has an active transaction running.
199
- - [Enhancement] Introduce `instrument_on_wait_queue_full` flag (defaults to `true`) to be able to configure whether non critical (retryable) queue full errors should be instrumented in the error pipeline. Useful when building high-performance pipes with WaterDrop queue retry backoff as a throttler.
205
+ - [Enhancement] Introduce `instrument_on_wait_queue_full` (defaults to `true`) to control whether retryable queue full errors are instrumented in the error pipeline.
200
206
  - [Enhancement] Protect critical `rdkafka` thread executable code sections.
201
207
  - [Enhancement] Treat the queue size as a gauge rather than a cumulative stat (isturdy).
202
208
  - [Fix] Fix a case where purge on non-initialized client would crash.
@@ -220,7 +226,7 @@ This release contains **BREAKING** changes. Make sure to read and apply upgrade
220
226
  - [Fix] Fix a case where buffered test client would not accumulate messages on failed transactions
221
227
 
222
228
  ## 2.6.10 (2023-10-24)
223
- - [Improvement] Introduce `message.purged` event to indicate that a message that was not delivered to Kafka was purged. This most of the time refers to messages that were part of a transaction and were not yet dispatched to Kafka. It always means, that given message was not delivered but in case of transactions it is expected. In case of non-transactional it usually means `#purge` usage or exceeding `message.timeout.ms` so `librdkafka` removes this message from its internal queue. Non-transactional producers do **not** use this and pipe purges to `error.occurred`.
229
+ - [Improvement] Introduce the `message.purged` event for a transactional message purged before delivery to Kafka. Non-transactional producers report purges via `error.occurred` instead.
224
230
  - [Fix] Fix a case where `message.acknowledged` would not have `caller` key.
225
231
  - [Fix] Fix a bug where critical errors (like `IRB::Abort`) would not abort the ongoing transaction.
226
232
 
@@ -274,7 +280,7 @@ This release contains **BREAKING** changes. Make sure to read and apply upgrade
274
280
  - [Improvement] Make `#produce` method private to avoid confusion and make sure it is not used directly (it is not part of the official API).
275
281
  - [Change] Change `wait_on_queue_full` from `false` to `true` as a default.
276
282
  - [Change] Rename `wait_on_queue_full_timeout` to `wait_backoff_on_queue_full` to match what it actually does.
277
- - [Enhancement] Introduce `wait_timeout_on_queue_full` with proper meaning. That is, this represents time after which despite backoff the error will be raised. This should allow to raise an error in case the backoff attempts were insufficient. This prevents from a case, where upon never deliverable messages we would end up with an infinite loop.
283
+ - [Enhancement] Introduce `wait_timeout_on_queue_full`: the time after which the error is raised despite backoff, so never-deliverable messages no longer cause an infinite loop.
278
284
  - [Fix] Provide `type` for queue full errors that references the appropriate public API method correctly.
279
285
 
280
286
  ## 2.5.3 (2023-05-26)
data/Gemfile CHANGED
@@ -4,7 +4,6 @@ source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
6
 
7
- # Relaxed from 2.7 because we support Ruby 3.1
8
7
  gem "zeitwerk", "~> 2.8.0"
9
8
 
10
9
  group :development do
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- waterdrop (2.10.4)
4
+ waterdrop (2.10.6)
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.21.2)
19
- karafka-core (2.6.2)
17
+ io-console (0.9.4)
18
+ json (3.0.2)
19
+ karafka-core (2.6.4)
20
20
  karafka-rdkafka (>= 0.20.0)
21
21
  logger (>= 1.6.0)
22
- karafka-rdkafka (0.28.0)
22
+ karafka-rdkafka (0.30.2)
23
23
  ffi (~> 1.17.1)
24
24
  json (> 2.0)
25
25
  logger
@@ -35,10 +35,10 @@ 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.1.1)
41
+ simplecov (1.3.2)
42
42
  warning (1.6.0)
43
43
  zeitwerk (2.8.3)
44
44
 
@@ -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.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
66
- karafka-core (2.6.2) sha256=c2fd7f277201b8ca97b824b364ad76bf776b8f5527bc422dafb71f7ae48d3a13
67
- karafka-rdkafka (0.28.0) sha256=fd845140c42341c5892215cac1e8adacda53b0461688f2e16379d82cb33eb006
64
+ io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
65
+ json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df
66
+ karafka-core (2.6.4) sha256=e08166ca7ef12fadf0500f2a3aba63b5ba918a98533c3feaac9f43c90f159363
67
+ karafka-rdkafka (0.30.2) sha256=11d565109ab9ebc6216020d6097bd9adbc39a9e599fb2a8cc5af93965b9008b7
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,11 +72,11 @@ 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.1.1) sha256=25825ef13f0b2e74694d769817dad6ab8e90131dabdaa666e522fea105521e78
77
+ simplecov (1.3.2) sha256=2a22ec1759f1b8db60881a2e038758956fc69ef93ed669be0bde0678de6d0e85
78
78
  warning (1.6.0) sha256=a49cdfae19fb77d19afff2efbe45f8ab759e9cd25b4e4ce2c79dbaf46bdb6c9e
79
- waterdrop (2.10.4)
79
+ waterdrop (2.10.6)
80
80
  zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5
81
81
 
82
82
  BUNDLED WITH
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.
@@ -16,7 +16,7 @@ It:
16
16
  - Supports multiple delivery policies
17
17
  - Supports per-topic configuration alterations (variants)
18
18
  - Works with [async](https://github.com/socketry/async) gems ecosystem
19
- - Works with Kafka `1.0+` and Ruby `3.2+`
19
+ - Works with Kafka `1.0+` and Ruby `3.3+`
20
20
  - Works with and without Karafka
21
21
 
22
22
  ## Documentation
@@ -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
@@ -17,6 +17,14 @@ module WaterDrop
17
17
  # into a new object.
18
18
  # @note You need to decide yourself whether you don't use the message hash data anywhere else
19
19
  # and you want to save on memory by modifying it in place or do you want to do a deep copy
20
+ # @note Steps run sequentially and a chain run is not atomic. If an in-place step mutates the
21
+ # message and a later step raises, the message is left partially transformed. During a
22
+ # buffer flush that message is re-buffered as not yet processed, so the next flush runs the
23
+ # whole chain over it again and the earlier in-place steps are applied twice (for example
24
+ # double encryption or duplicated headers). Making the run atomic would mean copying every
25
+ # message before its chain runs, which defeats the memory saving in-place mode is for, so
26
+ # this is a deliberate trade-off. If an in-place step can be followed by a step that may
27
+ # raise, make the in-place step idempotent or have it return a copy instead.
20
28
  def run(message)
21
29
  return message if @count.zero?
22
30
 
@@ -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,53 @@ 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
+ #
119
+ # Known edge case (#46840): the failing message is re-buffered in whatever state its chain
120
+ # left it. Copy-style steps leave it untouched, but in-place steps that ran before the
121
+ # raising one leave it partially transformed, and the next flush runs its full chain again.
122
+ # We accept this rather than snapshotting every message before its run, which would cost a
123
+ # copy per message on the success path as well. See Middleware#run.
124
+ processed = []
125
+
126
+ begin
127
+ fresh.each { |message| processed << middleware.run(message) }
128
+ rescue
129
+ restore_undispatched(fresh.drop(processed.size), requeued.concat(processed))
130
+
131
+ raise
132
+ end
133
+
134
+ data_for_dispatch = requeued.concat(processed)
135
+
88
136
  # Do nothing if nothing to flush
89
137
  return data_for_dispatch if data_for_dispatch.empty?
90
138
 
91
- sync ? produce_many_sync(data_for_dispatch) : produce_many_async(data_for_dispatch)
139
+ if sync
140
+ produce_many_sync(data_for_dispatch, run_middleware: false)
141
+ else
142
+ produce_many_async(data_for_dispatch, run_middleware: false)
143
+ end
92
144
  rescue Errors::ProduceManyError => e
93
145
  # A dispatch failed partway through the batch. Re-buffer the messages that never reached
94
146
  # librdkafka so a partial failure does not silently drop valid buffered messages. For a
@@ -106,15 +158,27 @@ module WaterDrop
106
158
  raise
107
159
  end
108
160
 
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.
161
+ # Puts both sets back after a failure that happened before anything reached librdkafka. They
162
+ # go to different buffers because middleware has run on one of them and not the other, which
163
+ # is what keeps it applied exactly once per message.
164
+ #
165
+ # @param fresh [Array<Hash>] messages whose middleware chain has not completed
166
+ # @param requeued [Array<Hash>] already middleware-processed messages
167
+ def restore_undispatched(fresh, requeued)
168
+ @buffer_mutex.synchronize do
169
+ @messages.unshift(*fresh)
170
+ @requeued.unshift(*requeued)
171
+ end
172
+ end
173
+
174
+ # Puts not-yet-dispatched messages back at the front of the retry buffer (preserving their
175
+ # original order relative to each other), so a failed flush does not lose them.
112
176
  #
113
- # @param messages [Array<Hash>] messages to restore to the buffer
177
+ # @param messages [Array<Hash>] already middleware-processed messages to restore
114
178
  def requeue_unflushed(messages)
115
179
  return if messages.empty?
116
180
 
117
- @buffer_mutex.synchronize { @messages.unshift(*messages) }
181
+ @buffer_mutex.synchronize { @requeued.unshift(*messages) }
118
182
  end
119
183
  end
120
184
  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 = []
@@ -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(
@@ -177,6 +178,12 @@ module WaterDrop
177
178
  @client
178
179
  end
179
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
+
180
187
  # Returns the number of messages in the librdkafka producer queue.
181
188
  #
182
189
  # This count includes:
@@ -232,6 +239,7 @@ module WaterDrop
232
239
  @monitor.instrument("buffer.purged", producer_id: id) do
233
240
  @buffer_mutex.synchronize do
234
241
  @messages = []
242
+ @requeued = []
235
243
  end
236
244
 
237
245
  # We should not purge if there is no client initialized
@@ -321,6 +329,7 @@ module WaterDrop
321
329
  return false unless @client
322
330
  return false unless @status.connected?
323
331
  return false unless @messages.empty?
332
+ return false unless @requeued.empty?
324
333
  return false unless @operations_in_progress.value.zero?
325
334
 
326
335
  @status.disconnecting!
@@ -354,6 +363,7 @@ module WaterDrop
354
363
  return false unless @client
355
364
  return false unless @status.connected?
356
365
  return false unless @messages.empty?
366
+ return false unless @requeued.empty?
357
367
  return false if @transaction_mutex.locked?
358
368
  return false if @operating_mutex.locked?
359
369
 
@@ -503,7 +513,7 @@ module WaterDrop
503
513
  # Try to get buffer info safely
504
514
  if @buffer_mutex.try_lock
505
515
  begin
506
- parts << "buffer_size=#{@messages.size}"
516
+ parts << "buffer_size=#{@messages.size + @requeued.size}"
507
517
  ensure
508
518
  @buffer_mutex.unlock
509
519
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module WaterDrop
4
4
  # Current WaterDrop version
5
- VERSION = "2.10.4"
5
+ VERSION = "2.10.6"
6
6
  end
data/package-lock.json CHANGED
@@ -103,9 +103,9 @@
103
103
  }
104
104
  },
105
105
  "node_modules/fastq": {
106
- "version": "1.20.1",
107
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
108
- "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
106
+ "version": "1.20.3",
107
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz",
108
+ "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==",
109
109
  "dev": true,
110
110
  "license": "ISC",
111
111
  "dependencies": {
@@ -286,9 +286,9 @@
286
286
  }
287
287
  },
288
288
  "node_modules/smol-toml": {
289
- "version": "1.7.1",
290
- "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
291
- "integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
289
+ "version": "1.9.0",
290
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
291
+ "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
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.9.0",
316
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
317
- "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
315
+ "version": "2.9.1",
316
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
317
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
318
318
  "dev": true,
319
319
  "license": "ISC",
320
320
  "bin": {
data/renovate.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
+ "bundler": {
3
+ "managerFilePatterns": ["/(^|/)Gemfile\\.lint$/"]
4
+ },
2
5
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
6
  "extends": [
4
7
  "config:recommended"
data/waterdrop.gemspec CHANGED
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
20
20
  spec.add_dependency "karafka-rdkafka", ">= 0.24.0"
21
21
  spec.add_dependency "zeitwerk", "~> 2.3"
22
22
 
23
- spec.required_ruby_version = ">= 3.2.0"
23
+ spec.required_ruby_version = ">= 3.3.0"
24
24
 
25
25
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match?(%r{^(spec|test|\.github|log)/}) }
26
26
  spec.executables = []
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.4
4
+ version: 2.10.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld
@@ -153,14 +153,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
153
153
  requirements:
154
154
  - - ">="
155
155
  - !ruby/object:Gem::Version
156
- version: 3.2.0
156
+ version: 3.3.0
157
157
  required_rubygems_version: !ruby/object:Gem::Requirement
158
158
  requirements:
159
159
  - - ">="
160
160
  - !ruby/object:Gem::Version
161
161
  version: '0'
162
162
  requirements: []
163
- rubygems_version: 4.0.16
163
+ rubygems_version: 4.0.20
164
164
  specification_version: 4
165
165
  summary: Kafka messaging made easy!
166
166
  test_files: []