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 +4 -4
- data/.gitignore +2 -0
- data/.ruby-version +1 -1
- data/.yard-lint.yml +29 -1
- data/CHANGELOG.md +34 -23
- data/Gemfile.lint.lock +8 -7
- data/Gemfile.lock +16 -16
- data/README.md +2 -2
- data/bin/verify_kafka_warnings +5 -0
- data/docker-compose.oauth.yml +3 -0
- data/docker-compose.sasl.yml +3 -0
- data/docker-compose.yml +3 -0
- data/lib/waterdrop/clients/buffered.rb +1 -2
- data/lib/waterdrop/clients/rdkafka.rb +2 -2
- data/lib/waterdrop/config.rb +2 -2
- data/lib/waterdrop/connection_pool.rb +3 -8
- data/lib/waterdrop/instrumentation/monitor.rb +4 -5
- data/lib/waterdrop/polling/config.rb +0 -2
- data/lib/waterdrop/polling/poller.rb +4 -6
- data/lib/waterdrop/polling/state.rb +6 -7
- data/lib/waterdrop/polling.rb +0 -1
- data/lib/waterdrop/producer/async.rb +5 -2
- data/lib/waterdrop/producer/buffer.rb +71 -13
- data/lib/waterdrop/producer/sync.rb +5 -2
- data/lib/waterdrop/producer/tombstone.rb +2 -2
- data/lib/waterdrop/producer/transactions.rb +4 -4
- data/lib/waterdrop/producer/variant.rb +1 -1
- data/lib/waterdrop/producer.rb +46 -10
- data/lib/waterdrop/version.rb +1 -2
- data/lib/waterdrop.rb +0 -1
- data/package-lock.json +3 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d61c97700e2882107b4e3309176c22fc85d06df909e3f11f0779bb33cab0ec96
|
|
4
|
+
data.tar.gz: 1b4c0e3c7ac54d6b86905544396865934ee783ba730dc0ccc6b3bd6b954a7ce8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d509ae8aafda829f217357e6d8447905910137a4af850cd8d4bac7a35bb3af703432339c05fa45117dd8acff2a481718f18a94dd7e7ea0ba0259a86b6ae8d42
|
|
7
|
+
data.tar.gz: 4a8c01eb9aa11c49eb810888a65a520731298ea500bdcaa2c0b78bc163ffc346cc3975f8cad06ea7e50c9c9d21357457eaf745269b35e598a9eccebaf611e7a4
|
data/.gitignore
CHANGED
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.0.
|
|
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:
|
|
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)
|
|
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
|
|
8
|
-
- [Enhancement] Stop allocating one interpolated string per message in `LoggerListener` batch produce handlers.
|
|
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.
|
|
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
|
|
12
|
-
- [Enhancement] Do not allocate the fiber-local variants hash on the `Producer#current_variant` read path
|
|
13
|
-
- [Enhancement] Cache the variant validation contract in a constant instead of instantiating a new `Contracts::Variant` on every `Producer#with` / `Producer#variant` call
|
|
14
|
-
- [Enhancement] Cache the tombstone validation contract in a constant instead of instantiating a new `Contracts::Tombstone` per tombstone message
|
|
15
|
-
- [Enhancement] Replace explicit `Warning[:performance]` opt-in with a dynamic approach using `Warning.categories`
|
|
16
|
-
- [Fix] Prevent a deadlock between a transactional single-message dispatch and `#close
|
|
17
|
-
- [Fix] Prevent a deadlock (`ThreadError: deadlock; recursive locking`) when closing an idempotent producer (with `reload_on_idempotent_fatal_error` enabled)
|
|
18
|
-
- [Fix] Make concurrent idempotent fatal-error reload thread-safe
|
|
19
|
-
- [Fix] Stop `#flush_async` / `#flush_sync` from silently dropping valid buffered messages when the dispatch fails
|
|
20
|
-
- [Fix] Make `Producer#close` fork-safe so the GC finalizer inherited by a forked child can no longer
|
|
21
|
-
- [Fix] Guard the internal buffer appends in `Producer#buffer` and `Producer#buffer_many` with `@buffer_mutex
|
|
22
|
-
- [Fix] Stop a nested same-producer variant call from clobbering the outer variant inside a variant `transaction` block
|
|
23
|
-
- [Fix] Stop `ConnectionPool#shutdown` and `#reload` from silently dropping in-flight messages.
|
|
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
|
|
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
|
|
31
|
-
- **[Breaking]** Skip emitting librdkafka statistics when nothing is subscribed to `statistics.emitted` at the time the underlying rdkafka client is constructed
|
|
32
|
-
- **[Breaking]** Raise `WaterDrop::Errors::StatisticsNotEnabledError` when attempting to subscribe to `statistics.emitted`
|
|
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
|
|
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
|
|
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.
|
|
62
|
-
yard-lint (1.
|
|
61
|
+
yard (0.9.45)
|
|
62
|
+
yard-lint (1.10.1)
|
|
63
63
|
yard (~> 0.9)
|
|
64
64
|
zeitwerk (~> 2.6)
|
|
65
|
-
zeitwerk (2.
|
|
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.
|
|
104
|
-
yard-lint (1.
|
|
105
|
-
zeitwerk (2.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
18
|
-
json (2.
|
|
19
|
-
karafka-core (2.6.
|
|
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.
|
|
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.
|
|
38
|
+
reline (0.7.0)
|
|
39
39
|
io-console (~> 0.5)
|
|
40
40
|
ruby2_keywords (0.0.5)
|
|
41
|
-
simplecov (1.
|
|
41
|
+
simplecov (1.3.0)
|
|
42
42
|
warning (1.6.0)
|
|
43
|
-
zeitwerk (2.8.
|
|
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.
|
|
65
|
-
json (2.
|
|
66
|
-
karafka-core (2.6.
|
|
67
|
-
karafka-rdkafka (0.
|
|
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.
|
|
75
|
+
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
|
|
76
76
|
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
|
|
77
|
-
simplecov (1.
|
|
77
|
+
simplecov (1.3.0) sha256=d9886307863c1ead47657dcbed869bfffd82318808b09014d8f0ff77dcfe7492
|
|
78
78
|
warning (1.6.0) sha256=a49cdfae19fb77d19afff2efbe45f8ab759e9cd25b4e4ce2c79dbaf46bdb6c9e
|
|
79
|
-
waterdrop (2.10.
|
|
80
|
-
zeitwerk (2.8.
|
|
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
|
-
[](https://github.com/karafka/waterdrop/actions
|
|
4
|
-
[](
|
|
3
|
+
[](https://github.com/karafka/waterdrop/actions/workflows/ci.yml)
|
|
4
|
+
[](https://badge.fury.io/rb/waterdrop)
|
|
5
5
|
[](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.
|
data/bin/verify_kafka_warnings
CHANGED
|
@@ -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"
|
data/docker-compose.oauth.yml
CHANGED
|
@@ -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"
|
data/docker-compose.sasl.yml
CHANGED
|
@@ -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
|
|
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
|
|
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]
|
data/lib/waterdrop/config.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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`
|
|
33
|
-
# responds to `on_statistics_emitted`
|
|
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
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,
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
data/lib/waterdrop/polling.rb
CHANGED
|
@@ -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
|
-
)
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
97
|
+
fresh = nil
|
|
98
|
+
requeued = nil
|
|
99
|
+
data_for_dispatch = []
|
|
82
100
|
|
|
83
101
|
@buffer_mutex.synchronize do
|
|
84
|
-
|
|
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
|
|
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
|
|
110
|
-
#
|
|
111
|
-
#
|
|
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
|
|
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 { @
|
|
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
|
|
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
|
|
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
|
|
286
|
-
# produce still in flight can therefore reach a coordinator that does not yet consider
|
|
287
|
-
# transaction started, which fails the abort with a fatal `INVALID_TXN_STATE`.
|
|
288
|
-
#
|
|
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
|
-
#
|
|
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?
|
data/lib/waterdrop/producer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
594
|
-
# as `#close` (`@transaction_mutex` -> `@operating_mutex` -> operations counter) and
|
|
595
|
-
# lock-order inversion: without it, a dispatch that had already counted itself could
|
|
596
|
-
# on `@transaction_mutex` held by a concurrent `#close` that was itself waiting
|
|
597
|
-
# counter to drain. When we already own the transaction lock (inside an
|
|
598
|
-
# or the closing flush) the order is already correct, so we dispatch
|
|
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
|
#
|
data/lib/waterdrop/version.rb
CHANGED
data/lib/waterdrop.rb
CHANGED
data/package-lock.json
CHANGED
|
@@ -286,9 +286,9 @@
|
|
|
286
286
|
}
|
|
287
287
|
},
|
|
288
288
|
"node_modules/smol-toml": {
|
|
289
|
-
"version": "1.
|
|
290
|
-
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.
|
|
291
|
-
"integrity": "sha512-
|
|
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.
|
|
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.
|
|
163
|
+
rubygems_version: 4.0.20
|
|
164
164
|
specification_version: 4
|
|
165
165
|
summary: Kafka messaging made easy!
|
|
166
166
|
test_files: []
|