karafka-core 2.5.10 → 2.5.11
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/.github/workflows/ci.yml +8 -8
- data/.github/workflows/push.yml +2 -2
- data/.ruby-version +1 -1
- data/CHANGELOG.md +13 -0
- data/Gemfile.lock +1 -1
- data/lib/karafka/core/configurable/node.rb +8 -6
- data/lib/karafka/core/contractable/contract.rb +35 -9
- data/lib/karafka/core/contractable/result.rb +9 -0
- data/lib/karafka/core/instrumentation/callbacks_manager.rb +6 -1
- data/lib/karafka/core/monitoring/event.rb +21 -4
- data/lib/karafka/core/monitoring/notifications.rb +5 -16
- data/lib/karafka/core/monitoring/statistics_decorator.rb +192 -39
- data/lib/karafka/core/version.rb +1 -1
- data/test/lib/karafka/core/monitoring/event_test.rb +37 -0
- data/test/lib/karafka/core/monitoring/statistics_decorator_test.rb +249 -30
- 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: 6ada2e9b6d4c3d9adfe4aa595f9208198b60eee63bed805501a48d9095da1d90
|
|
4
|
+
data.tar.gz: 77fb9a1be85a9f120057e784385cfb667ec5e65e0461a2fb059fc4e76e386a0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f22a703bb815c99259ab70f53adb090b8100dcf7bfb91785706fbd2a91a8bba02a12ef5412d93e4459cfc4ac51e9bbce4f4f1ead392317255f588b823eeb356
|
|
7
|
+
data.tar.gz: 33cd8fa76ca78714edbcc9ddb052d345ddeb0e63bc569e718ff47feef7db273c52a5288801c35d88c4248b91904a442537b827ea749c72a09fb7724b85bf2bf7
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -14,7 +14,7 @@ permissions:
|
|
|
14
14
|
contents: read
|
|
15
15
|
|
|
16
16
|
jobs:
|
|
17
|
-
|
|
17
|
+
tests:
|
|
18
18
|
timeout-minutes: 15
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
20
|
strategy:
|
|
@@ -37,7 +37,7 @@ jobs:
|
|
|
37
37
|
run: "[ -e $APT_DEPS ] || sudo apt-get install -y --no-install-recommends $APT_DEPS"
|
|
38
38
|
|
|
39
39
|
- name: Set up Ruby
|
|
40
|
-
uses: ruby/setup-ruby@
|
|
40
|
+
uses: ruby/setup-ruby@eab2afb99481ca09a4e91171a8e0aee0e89bfedd # v1.296.0
|
|
41
41
|
with:
|
|
42
42
|
ruby-version: ${{matrix.ruby}}
|
|
43
43
|
bundler: 'latest'
|
|
@@ -69,9 +69,9 @@ jobs:
|
|
|
69
69
|
with:
|
|
70
70
|
fetch-depth: 0
|
|
71
71
|
- name: Set up Ruby
|
|
72
|
-
uses: ruby/setup-ruby@
|
|
72
|
+
uses: ruby/setup-ruby@eab2afb99481ca09a4e91171a8e0aee0e89bfedd # v1.296.0
|
|
73
73
|
with:
|
|
74
|
-
ruby-version: '4.0.
|
|
74
|
+
ruby-version: '4.0.2'
|
|
75
75
|
bundler-cache: true
|
|
76
76
|
- name: Run rubocop
|
|
77
77
|
run: bundle exec rubocop
|
|
@@ -86,9 +86,9 @@ jobs:
|
|
|
86
86
|
with:
|
|
87
87
|
fetch-depth: 0
|
|
88
88
|
- name: Set up Ruby
|
|
89
|
-
uses: ruby/setup-ruby@
|
|
89
|
+
uses: ruby/setup-ruby@eab2afb99481ca09a4e91171a8e0aee0e89bfedd # v1.296.0
|
|
90
90
|
with:
|
|
91
|
-
ruby-version: '4.0.
|
|
91
|
+
ruby-version: '4.0.2'
|
|
92
92
|
bundler-cache: true
|
|
93
93
|
- name: Run yard-lint
|
|
94
94
|
run: bundle exec yard-lint lib/
|
|
@@ -101,7 +101,7 @@ jobs:
|
|
|
101
101
|
with:
|
|
102
102
|
fetch-depth: 0
|
|
103
103
|
- name: Set up Node.js
|
|
104
|
-
uses: actions/setup-node@
|
|
104
|
+
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
|
105
105
|
with:
|
|
106
106
|
node-version: '20'
|
|
107
107
|
cache: 'npm'
|
|
@@ -116,7 +116,7 @@ jobs:
|
|
|
116
116
|
if: always()
|
|
117
117
|
needs:
|
|
118
118
|
- rubocop
|
|
119
|
-
-
|
|
119
|
+
- tests
|
|
120
120
|
- yard-lint
|
|
121
121
|
- lostconf
|
|
122
122
|
steps:
|
data/.github/workflows/push.yml
CHANGED
|
@@ -24,7 +24,7 @@ jobs:
|
|
|
24
24
|
fetch-depth: 0
|
|
25
25
|
|
|
26
26
|
- name: Set up Ruby
|
|
27
|
-
uses: ruby/setup-ruby@
|
|
27
|
+
uses: ruby/setup-ruby@eab2afb99481ca09a4e91171a8e0aee0e89bfedd # v1.296.0
|
|
28
28
|
with:
|
|
29
29
|
bundler-cache: false
|
|
30
30
|
|
|
@@ -32,4 +32,4 @@ jobs:
|
|
|
32
32
|
run: |
|
|
33
33
|
bundle install --jobs 4 --retry 3
|
|
34
34
|
|
|
35
|
-
- uses: rubygems/release-gem@
|
|
35
|
+
- uses: rubygems/release-gem@e9a6361a0b14562539327c2a02373edc56dd3169 # v1.1.4
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.0.
|
|
1
|
+
4.0.2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Karafka Core Changelog
|
|
2
2
|
|
|
3
|
+
## 2.5.11 (2026-04-02)
|
|
4
|
+
- [Enhancement] Specialize `Contract#dig` for common 1-key and 2-key paths to avoid iterator overhead, yielding ~1.5x faster single-key lookups and ~1.45x faster two-key nested lookups.
|
|
5
|
+
- [Enhancement] Replace `Node#build_accessors` `@local_defs` Array with Hash for O(1) membership checks instead of O(n) `Array#include?`, yielding up to ~5x faster accessor lookups at 50 settings.
|
|
6
|
+
- [Enhancement] Use frozen `EMPTY_ARRAY` constant for `Contract#call` and `#validate!` default `scope` parameter to avoid allocating a new Array on every invocation, yielding ~1.36x faster call dispatch and saving 1 Array allocation per call.
|
|
7
|
+
- [Enhancement] Pre-resolve `@events_methods_map` method name before the listener notification loop in `Notifications#instrument` to avoid repeated Hash lookup per listener, yielding ~1.12x faster event dispatch with multiple listeners.
|
|
8
|
+
- [Enhancement] Cache a frozen success `Result` singleton via `Result.success` to eliminate 1 object allocation per successful `Contract#call` on the happy path.
|
|
9
|
+
- [Enhancement] Skip nestings block re-evaluation in `Node#deep_dup` to avoid recreating children that are immediately overwritten, yielding ~14x faster deep_dup and reducing allocations from ~620 to ~66 objects for large configs.
|
|
10
|
+
- [Enhancement] Cache `CallbacksManager#call` values snapshot and invalidate on `add`/`delete` to avoid allocating a new Array on every invocation while preserving thread-safety snapshot semantics, saving 1 Array allocation per call.
|
|
11
|
+
- [Enhancement] Store execution time separately in `Event` and build the merged payload hash lazily on `#payload` access, eliminating 1 Hash allocation per `Notifications#instrument` call when listeners use `#[]` access (the common pattern), yielding ~1.7x faster event dispatch.
|
|
12
|
+
- [Enhancement] Replace `StatisticsDecorator#diff` pending-writes buffer with `keys.each` direct-write iteration, eliminating the buffer and write-back loop for ~13% faster decoration at scale (10 brokers, 20 topics, 2000 partitions).
|
|
13
|
+
- [Enhancement] Reorder `StatisticsDecorator#diff` type checks to test `Numeric` before `Hash`, matching the ~80% numeric value distribution in librdkafka statistics.
|
|
14
|
+
- [Enhancement] Support `only_keys` option in `StatisticsDecorator` to decorate only specified numeric keys (e.g. `consumer_lag`, `committed_offset`). When combined with `excluded_keys`, reduces decoration cost from ~80ms to ~8.5ms per call on large clusters (10 brokers, 20 topics, 2000 partitions) by using structure-aware navigation of the librdkafka statistics tree and direct key access instead of full-hash iteration.
|
|
15
|
+
|
|
3
16
|
## 2.5.10 (2026-03-02)
|
|
4
17
|
- [Enhancement] Introduce `MinitestLocator` helper for minitest/spec subject class auto-discovery from test file paths.
|
|
5
18
|
|
data/Gemfile.lock
CHANGED
|
@@ -17,14 +17,16 @@ module Karafka
|
|
|
17
17
|
|
|
18
18
|
# @param node_name [Symbol] node name
|
|
19
19
|
# @param nestings [Proc] block for nested settings
|
|
20
|
-
|
|
20
|
+
# @param evaluate [Boolean] when false, skip evaluating the nestings block. Used by
|
|
21
|
+
# deep_dup to avoid re-creating children that will be overwritten immediately.
|
|
22
|
+
def initialize(node_name, nestings = ->(_) {}, evaluate: true)
|
|
21
23
|
@node_name = node_name
|
|
22
24
|
@children = []
|
|
23
25
|
@nestings = nestings
|
|
24
26
|
@compiled = false
|
|
25
27
|
@configs_refs = {}
|
|
26
|
-
@local_defs =
|
|
27
|
-
instance_eval(&nestings)
|
|
28
|
+
@local_defs = {}
|
|
29
|
+
instance_eval(&nestings) if evaluate
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
# Allows for a single leaf or nested node definition
|
|
@@ -83,7 +85,7 @@ module Karafka
|
|
|
83
85
|
# and non-side-effect usage on an instance/inherited.
|
|
84
86
|
# @return [Node] duplicated node
|
|
85
87
|
def deep_dup
|
|
86
|
-
dupped = Node.new(node_name, nestings)
|
|
88
|
+
dupped = Node.new(node_name, nestings, evaluate: false)
|
|
87
89
|
|
|
88
90
|
children.each do |value|
|
|
89
91
|
dupped.children << if value.is_a?(Leaf)
|
|
@@ -189,8 +191,8 @@ module Karafka
|
|
|
189
191
|
# object and then we would not redefine it for nodes. This ensures that we only do not
|
|
190
192
|
# redefine our own definitions but we do redefine any user "accidentally" inherited
|
|
191
193
|
# methods
|
|
192
|
-
if reader_respond ? !@local_defs.
|
|
193
|
-
@local_defs
|
|
194
|
+
if reader_respond ? !@local_defs.key?(reader_name) : true
|
|
195
|
+
@local_defs[reader_name] = true
|
|
194
196
|
|
|
195
197
|
define_singleton_method(reader_name) do
|
|
196
198
|
@configs_refs[reader_name]
|
|
@@ -14,7 +14,12 @@ module Karafka
|
|
|
14
14
|
# prevent additional array allocation
|
|
15
15
|
DIG_MISS = Object.new
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# Empty array for scope default to avoid allocating a new Array on each
|
|
18
|
+
# `#call` / `#validate!` invocation. Safe because scope is never mutated – it is only
|
|
19
|
+
# used in `scope + rule.path` which creates a new Array.
|
|
20
|
+
EMPTY_ARRAY = [].freeze
|
|
21
|
+
|
|
22
|
+
private_constant :DIG_MISS, :EMPTY_ARRAY
|
|
18
23
|
|
|
19
24
|
# Yaml based error messages data
|
|
20
25
|
setting(:error_messages)
|
|
@@ -80,7 +85,7 @@ module Karafka
|
|
|
80
85
|
# @param scope [Array<String>] scope of this contract (if any) or empty array if no parent
|
|
81
86
|
# scope is needed if contract starts from root
|
|
82
87
|
# @return [Result] validaton result
|
|
83
|
-
def call(data, scope:
|
|
88
|
+
def call(data, scope: EMPTY_ARRAY)
|
|
84
89
|
errors = []
|
|
85
90
|
|
|
86
91
|
self.class.rules.each do |rule|
|
|
@@ -94,6 +99,8 @@ module Karafka
|
|
|
94
99
|
end
|
|
95
100
|
end
|
|
96
101
|
|
|
102
|
+
return Result.success if errors.empty?
|
|
103
|
+
|
|
97
104
|
Result.new(errors, self)
|
|
98
105
|
end
|
|
99
106
|
|
|
@@ -104,7 +111,7 @@ module Karafka
|
|
|
104
111
|
# @return [Boolean] true
|
|
105
112
|
# @raise [StandardError] any error provided in the error_class that inherits from the
|
|
106
113
|
# standard error
|
|
107
|
-
def validate!(data, error_class, scope:
|
|
114
|
+
def validate!(data, error_class, scope: EMPTY_ARRAY)
|
|
108
115
|
result = call(data, scope: scope)
|
|
109
116
|
|
|
110
117
|
return true if result.success?
|
|
@@ -183,15 +190,34 @@ module Karafka
|
|
|
183
190
|
# @param keys [Array<Symbol>]
|
|
184
191
|
# @return [DIG_MISS, Object] found element or DIGG_MISS indicating that not found
|
|
185
192
|
def dig(data, keys)
|
|
186
|
-
|
|
193
|
+
case keys.length
|
|
194
|
+
when 1
|
|
195
|
+
key = keys[0]
|
|
187
196
|
|
|
188
|
-
|
|
189
|
-
return DIG_MISS unless current.key?(nesting)
|
|
197
|
+
return DIG_MISS unless data.key?(key)
|
|
190
198
|
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
data[key]
|
|
200
|
+
when 2
|
|
201
|
+
key1 = keys[0]
|
|
202
|
+
|
|
203
|
+
return DIG_MISS unless data.key?(key1)
|
|
204
|
+
|
|
205
|
+
mid = data[key1]
|
|
206
|
+
|
|
207
|
+
return DIG_MISS unless mid.is_a?(Hash) && mid.key?(keys[1])
|
|
193
208
|
|
|
194
|
-
|
|
209
|
+
mid[keys[1]]
|
|
210
|
+
else
|
|
211
|
+
current = data
|
|
212
|
+
|
|
213
|
+
keys.each do |nesting|
|
|
214
|
+
return DIG_MISS unless current.key?(nesting)
|
|
215
|
+
|
|
216
|
+
current = current[nesting]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
current
|
|
220
|
+
end
|
|
195
221
|
end
|
|
196
222
|
end
|
|
197
223
|
end
|
|
@@ -12,6 +12,15 @@ module Karafka
|
|
|
12
12
|
|
|
13
13
|
private_constant :EMPTY_HASH
|
|
14
14
|
|
|
15
|
+
class << self
|
|
16
|
+
# @return [Result] cached frozen success result to avoid allocating a new Result on
|
|
17
|
+
# every successful contract validation. Safe because successful results are
|
|
18
|
+
# identical regardless of contract (they all have @errors = EMPTY_HASH).
|
|
19
|
+
def success
|
|
20
|
+
@success ||= new([], nil).freeze
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
15
24
|
# Builds a result object and remaps (if needed) error keys to proper error messages
|
|
16
25
|
#
|
|
17
26
|
# @param errors [Array<Array>] array with sub-arrays with paths and error keys
|
|
@@ -10,6 +10,7 @@ module Karafka
|
|
|
10
10
|
# @return [::Karafka::Core::Instrumentation::CallbacksManager]
|
|
11
11
|
def initialize
|
|
12
12
|
@callbacks = {}
|
|
13
|
+
@values_cache = nil
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
# Invokes all the callbacks registered one after another
|
|
@@ -19,8 +20,10 @@ module Karafka
|
|
|
19
20
|
# callbacks and add new at the same time. Since we don't know when and in what thread
|
|
20
21
|
# things are going to be added to the manager, we need to extract values into an array and
|
|
21
22
|
# run it. That way we can add new things the same time.
|
|
23
|
+
# The values snapshot is cached and invalidated on add/delete to avoid allocating a new
|
|
24
|
+
# Array on every call while preserving the thread-safety snapshot semantics.
|
|
22
25
|
def call(*args)
|
|
23
|
-
@callbacks.values.each { |callback| callback.call(*args) }
|
|
26
|
+
(@values_cache ||= @callbacks.values).each { |callback| callback.call(*args) }
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
# Adds a callback to the manager
|
|
@@ -29,12 +32,14 @@ module Karafka
|
|
|
29
32
|
# @param callable [#call] object that responds to a `#call` method
|
|
30
33
|
def add(id, callable)
|
|
31
34
|
@callbacks[id] = callable
|
|
35
|
+
@values_cache = nil
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
# Removes the callback from the manager
|
|
35
39
|
# @param id [String] id of the callback we want to remove
|
|
36
40
|
def delete(id)
|
|
37
41
|
@callbacks.delete(id)
|
|
42
|
+
@values_cache = nil
|
|
38
43
|
end
|
|
39
44
|
end
|
|
40
45
|
end
|
|
@@ -5,20 +5,37 @@ module Karafka
|
|
|
5
5
|
module Monitoring
|
|
6
6
|
# Single notification event wrapping payload with id
|
|
7
7
|
class Event
|
|
8
|
-
attr_reader :id
|
|
8
|
+
attr_reader :id
|
|
9
9
|
|
|
10
10
|
# @param id [String, Symbol] id of the event
|
|
11
11
|
# @param payload [Hash] event payload
|
|
12
|
-
|
|
12
|
+
# @param time [Float, nil] execution time, stored separately to avoid eager hash
|
|
13
|
+
# allocation. Merged into the payload lazily only when `#payload` is accessed.
|
|
14
|
+
def initialize(id, payload, time = nil)
|
|
13
15
|
@id = id
|
|
14
|
-
@
|
|
16
|
+
@raw_payload = payload
|
|
17
|
+
@time = time
|
|
18
|
+
@payload = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Hash] full payload including time (if set). The merged hash is built lazily
|
|
22
|
+
# on first access to avoid allocating a new Hash when listeners only use `#[]`.
|
|
23
|
+
def payload
|
|
24
|
+
@payload ||= if @time
|
|
25
|
+
@raw_payload.empty? ? { time: @time } : @raw_payload.merge(time: @time)
|
|
26
|
+
else
|
|
27
|
+
@raw_payload
|
|
28
|
+
end
|
|
15
29
|
end
|
|
16
30
|
|
|
17
31
|
# Hash access to the payload data (if present)
|
|
32
|
+
# Provides direct access to time without triggering payload hash construction.
|
|
18
33
|
#
|
|
19
34
|
# @param name [String, Symbol]
|
|
20
35
|
def [](name)
|
|
21
|
-
@
|
|
36
|
+
return @time if name == :time && @time
|
|
37
|
+
|
|
38
|
+
@raw_payload.fetch(name)
|
|
22
39
|
end
|
|
23
40
|
end
|
|
24
41
|
end
|
|
@@ -139,36 +139,25 @@ module Karafka
|
|
|
139
139
|
return
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
event = Event.new(event_id, final_payload)
|
|
142
|
+
event = Event.new(event_id, payload, time)
|
|
144
143
|
|
|
145
|
-
notify_listeners(event_id, event, assigned_listeners)
|
|
144
|
+
notify_listeners(@events_methods_map[event_id], event, assigned_listeners)
|
|
146
145
|
|
|
147
146
|
result
|
|
148
147
|
end
|
|
149
148
|
|
|
150
149
|
private
|
|
151
150
|
|
|
152
|
-
# Builds the final payload with time information if available
|
|
153
|
-
# @param payload [Hash] original payload
|
|
154
|
-
# @param time [Float, nil] execution time if block was given
|
|
155
|
-
# @return [Hash] final payload
|
|
156
|
-
def build_payload(payload, time)
|
|
157
|
-
return payload unless time
|
|
158
|
-
|
|
159
|
-
payload.empty? ? { time: time } : payload.merge(time: time)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
151
|
# Notifies all assigned listeners about the event
|
|
163
|
-
# @param
|
|
152
|
+
# @param method_name [Symbol] pre-resolved method name for object listeners
|
|
164
153
|
# @param event [Event] event with payload to broadcast to listeners
|
|
165
154
|
# @param assigned_listeners [Array] list of listeners to notify
|
|
166
|
-
def notify_listeners(
|
|
155
|
+
def notify_listeners(method_name, event, assigned_listeners)
|
|
167
156
|
assigned_listeners.each do |listener|
|
|
168
157
|
if listener.is_a?(Proc)
|
|
169
158
|
listener.call(event)
|
|
170
159
|
else
|
|
171
|
-
listener.send(
|
|
160
|
+
listener.send(method_name, event)
|
|
172
161
|
end
|
|
173
162
|
end
|
|
174
163
|
end
|
|
@@ -6,7 +6,7 @@ module Karafka
|
|
|
6
6
|
# Many of the librdkafka statistics are absolute values instead of a gauge.
|
|
7
7
|
# This means, that for example number of messages sent is an absolute growing value
|
|
8
8
|
# instead of being a value of messages sent from the last statistics report.
|
|
9
|
-
# This decorator calculates the diff against previously
|
|
9
|
+
# This decorator calculates the diff against previously emitted stats, so we get also
|
|
10
10
|
# the diff together with the original values
|
|
11
11
|
#
|
|
12
12
|
# It adds two extra values to numerics:
|
|
@@ -28,7 +28,12 @@ module Karafka
|
|
|
28
28
|
# duration suffixes. This is useful for skipping large subtrees of the librdkafka
|
|
29
29
|
# statistics that are not consumed by the application (e.g. broker toppars, window
|
|
30
30
|
# stats like int_latency, outbuf_latency, throttle, batchsize, batchcnt, req).
|
|
31
|
-
|
|
31
|
+
# @param only_keys [Array<String>] when set, only these numeric keys will be decorated
|
|
32
|
+
# with delta/freeze duration suffixes. Hash children are still recursed into for
|
|
33
|
+
# structural navigation, but only the listed keys receive _d and _fd computation.
|
|
34
|
+
# This drastically reduces work at the partition level where most cost occurs.
|
|
35
|
+
# When empty (default), all numeric keys are decorated.
|
|
36
|
+
def initialize(excluded_keys: [], only_keys: [])
|
|
32
37
|
@previous = EMPTY_HASH
|
|
33
38
|
# Operate on ms precision only
|
|
34
39
|
@previous_at = monotonic_now.round
|
|
@@ -39,63 +44,222 @@ module Karafka
|
|
|
39
44
|
@excluded_keys = unless excluded_keys.empty?
|
|
40
45
|
excluded_keys.each_with_object({}) { |k, h| h[k] = true }.freeze
|
|
41
46
|
end
|
|
47
|
+
# Frozen array for direct-access decoration, nil when empty to use full decoration
|
|
48
|
+
@only_keys = unless only_keys.empty?
|
|
49
|
+
only_keys.freeze
|
|
50
|
+
end
|
|
42
51
|
end
|
|
43
52
|
|
|
44
|
-
# @param
|
|
45
|
-
# @return [Hash]
|
|
46
|
-
# @note We modify the
|
|
53
|
+
# @param emitted_stats [Hash] original emitted statistics
|
|
54
|
+
# @return [Hash] emitted statistics extended with the diff data
|
|
55
|
+
# @note We modify the emitted statistics, instead of creating new. Since we don't expose
|
|
47
56
|
# any API to get raw data, users can just assume that the result of this decoration is
|
|
48
57
|
# the proper raw stats that they can use
|
|
49
|
-
def call(
|
|
58
|
+
def call(emitted_stats)
|
|
50
59
|
current_at = monotonic_now.round
|
|
51
60
|
change_d = current_at - @previous_at
|
|
52
61
|
|
|
53
62
|
diff(
|
|
54
63
|
@previous,
|
|
55
|
-
|
|
56
|
-
[],
|
|
57
|
-
0,
|
|
64
|
+
emitted_stats,
|
|
58
65
|
change_d
|
|
59
66
|
)
|
|
60
67
|
|
|
61
|
-
@previous =
|
|
68
|
+
@previous = emitted_stats
|
|
62
69
|
@previous_at = current_at
|
|
63
70
|
|
|
64
|
-
|
|
71
|
+
emitted_stats.freeze
|
|
65
72
|
end
|
|
66
73
|
|
|
67
74
|
private
|
|
68
75
|
|
|
69
|
-
# Calculates the diff of the provided values
|
|
70
|
-
#
|
|
76
|
+
# Calculates the diff of the provided values and modifies the emitted statistics
|
|
77
|
+
# in place to add delta and freeze duration keys.
|
|
71
78
|
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
79
|
+
# When @only_keys is set, uses a two-phase approach: first recurse into hash
|
|
80
|
+
# children for structural navigation, then decorate only the specified keys via
|
|
81
|
+
# direct hash access (no iteration over non-target keys). This drastically reduces
|
|
82
|
+
# work at the partition level where most of the cost occurs.
|
|
75
83
|
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
84
|
+
# When @only_keys is nil, uses full decoration: iterates all keys, decorating every
|
|
85
|
+
# numeric value found.
|
|
78
86
|
#
|
|
79
87
|
# @param previous [Object] previous value from the given scope in which we are
|
|
80
88
|
# @param current [Object] current scope from emitted statistics
|
|
81
|
-
# @param pw [Array] pending writes buffer shared across recursive calls
|
|
82
|
-
# @param pw_start [Integer] starting offset in the buffer for this hash level
|
|
83
89
|
# @param change_d [Integer] time delta in ms since last stats emission
|
|
84
|
-
def diff(previous, current,
|
|
90
|
+
def diff(previous, current, change_d)
|
|
85
91
|
return unless current.is_a?(Hash)
|
|
86
92
|
|
|
93
|
+
if @only_keys
|
|
94
|
+
diff_only_keys(previous, current, change_d)
|
|
95
|
+
else
|
|
96
|
+
diff_all(previous, current, change_d)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Full decoration path: iterates all keys, decorating every numeric value.
|
|
101
|
+
#
|
|
102
|
+
# Uses `keys.each` to snapshot the current hash's key list, allowing direct writes
|
|
103
|
+
# to the hash during iteration without a pending-writes buffer.
|
|
104
|
+
#
|
|
105
|
+
# Checks Numeric before Hash because ~80% of statistics values are numeric.
|
|
106
|
+
#
|
|
107
|
+
# @param previous [Object] previous value from the given scope
|
|
108
|
+
# @param current [Hash] current scope from emitted statistics
|
|
109
|
+
# @param change_d [Integer] time delta in ms
|
|
110
|
+
def diff_all(previous, current, change_d)
|
|
87
111
|
filled_previous = previous || EMPTY_HASH
|
|
88
112
|
cache = @suffix_keys_cache
|
|
89
113
|
excluded = @excluded_keys
|
|
90
|
-
pw_size = pw_start
|
|
91
114
|
|
|
92
|
-
current.
|
|
115
|
+
current.keys.each do |key|
|
|
93
116
|
next if excluded&.key?(key)
|
|
94
117
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
118
|
+
value = current[key]
|
|
119
|
+
|
|
120
|
+
if value.is_a?(Numeric)
|
|
121
|
+
prev_value = filled_previous[key]
|
|
122
|
+
|
|
123
|
+
if prev_value.nil?
|
|
124
|
+
result = 0
|
|
125
|
+
elsif prev_value.is_a?(Numeric)
|
|
126
|
+
result = value - prev_value
|
|
127
|
+
else
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
pair = cache[key] || (cache[key] = ["#{key}_fd".freeze, "#{key}_d".freeze].freeze)
|
|
132
|
+
|
|
133
|
+
current[pair[0]] = (result == 0) ? (filled_previous[pair[0]] || 0) + change_d : 0
|
|
134
|
+
current[pair[1]] = result
|
|
135
|
+
elsif value.is_a?(Hash)
|
|
136
|
+
diff_all(filled_previous[key], value, change_d)
|
|
98
137
|
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Known librdkafka statistics tree structural keys. Used by diff_only_keys to
|
|
142
|
+
# navigate directly to known hash paths instead of iterating all keys.
|
|
143
|
+
KNOWN_HASH_KEYS = { "brokers" => true, "topics" => true, "cgrp" => true }.freeze
|
|
144
|
+
|
|
145
|
+
# Known structural keys within a topic hash
|
|
146
|
+
TOPIC_HASH_KEYS = { "partitions" => true }.freeze
|
|
147
|
+
|
|
148
|
+
private_constant :KNOWN_HASH_KEYS, :TOPIC_HASH_KEYS
|
|
149
|
+
|
|
150
|
+
# Selective decoration path: navigates known librdkafka statistics tree structure
|
|
151
|
+
# directly instead of iterating all keys to find hash children. Decorates only the
|
|
152
|
+
# specified keys via direct access at each level.
|
|
153
|
+
#
|
|
154
|
+
# This eliminates the generic recursion overhead (72,000+ is_a?(Hash) checks at the
|
|
155
|
+
# partition level alone on a 2000-partition cluster) by leveraging knowledge of the
|
|
156
|
+
# librdkafka statistics layout: root → brokers → broker, root → topics → topic →
|
|
157
|
+
# partitions → partition, root → cgrp.
|
|
158
|
+
#
|
|
159
|
+
# For non-librdkafka hash children (e.g. custom or test data), falls back to generic
|
|
160
|
+
# recursion to maintain correctness with arbitrary nested structures.
|
|
161
|
+
#
|
|
162
|
+
# @param previous [Object] previous value from the given scope
|
|
163
|
+
# @param current [Hash] current stats hash (root level)
|
|
164
|
+
# @param change_d [Integer] time delta in ms
|
|
165
|
+
def diff_only_keys(previous, current, change_d)
|
|
166
|
+
filled_previous = previous || EMPTY_HASH
|
|
167
|
+
excluded = @excluded_keys
|
|
168
|
+
|
|
169
|
+
# Root level decoration
|
|
170
|
+
decorate_keys(current, filled_previous, change_d)
|
|
171
|
+
|
|
172
|
+
# Brokers: each child is a broker hash (leaf-like for only_keys)
|
|
173
|
+
unless excluded&.key?("brokers")
|
|
174
|
+
brokers = current["brokers"]
|
|
175
|
+
|
|
176
|
+
if brokers.is_a?(Hash)
|
|
177
|
+
prev_brokers = filled_previous["brokers"] || EMPTY_HASH
|
|
178
|
+
|
|
179
|
+
brokers.each_pair do |name, broker|
|
|
180
|
+
decorate_keys(broker, prev_brokers[name] || EMPTY_HASH, change_d) if broker.is_a?(Hash)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Topics → Partitions
|
|
186
|
+
unless excluded&.key?("topics")
|
|
187
|
+
topics = current["topics"]
|
|
188
|
+
|
|
189
|
+
if topics.is_a?(Hash)
|
|
190
|
+
prev_topics = filled_previous["topics"] || EMPTY_HASH
|
|
191
|
+
|
|
192
|
+
topics.each_pair do |name, topic|
|
|
193
|
+
next unless topic.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
prev_topic = prev_topics[name] || EMPTY_HASH
|
|
196
|
+
decorate_keys(topic, prev_topic, change_d)
|
|
197
|
+
|
|
198
|
+
unless excluded&.key?("partitions")
|
|
199
|
+
partitions = topic["partitions"]
|
|
200
|
+
|
|
201
|
+
if partitions.is_a?(Hash)
|
|
202
|
+
prev_partitions = prev_topic["partitions"] || EMPTY_HASH
|
|
203
|
+
|
|
204
|
+
partitions.each_pair do |pid, partition|
|
|
205
|
+
# Leaf level: no hash children, just decorate
|
|
206
|
+
decorate_keys(partition, prev_partitions[pid] || EMPTY_HASH, change_d)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Consumer group (leaf-like)
|
|
215
|
+
cgrp = current["cgrp"]
|
|
216
|
+
decorate_keys(cgrp, filled_previous["cgrp"] || EMPTY_HASH, change_d) if cgrp.is_a?(Hash)
|
|
217
|
+
|
|
218
|
+
# Fallback: handle any non-standard hash children not in the known structure.
|
|
219
|
+
# This ensures correctness for arbitrary nested data while the known paths above
|
|
220
|
+
# provide the fast path for real librdkafka statistics.
|
|
221
|
+
current.each_pair do |key, value|
|
|
222
|
+
next if KNOWN_HASH_KEYS.key?(key)
|
|
223
|
+
next if excluded&.key?(key)
|
|
224
|
+
next unless value.is_a?(Hash)
|
|
225
|
+
|
|
226
|
+
diff_only_keys_generic(filled_previous[key], value, change_d)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Generic recursive fallback for only_keys mode on non-standard hash children.
|
|
231
|
+
# Used for hash subtrees not covered by the known librdkafka structure.
|
|
232
|
+
#
|
|
233
|
+
# @param previous [Object] previous value
|
|
234
|
+
# @param current [Hash] current hash to process
|
|
235
|
+
# @param change_d [Integer] time delta in ms
|
|
236
|
+
def diff_only_keys_generic(previous, current, change_d)
|
|
237
|
+
return unless current.is_a?(Hash)
|
|
238
|
+
|
|
239
|
+
filled_previous = previous || EMPTY_HASH
|
|
240
|
+
excluded = @excluded_keys
|
|
241
|
+
|
|
242
|
+
decorate_keys(current, filled_previous, change_d)
|
|
243
|
+
|
|
244
|
+
current.each_pair do |key, value|
|
|
245
|
+
next if excluded&.key?(key)
|
|
246
|
+
|
|
247
|
+
diff_only_keys_generic(filled_previous[key], value, change_d) if value.is_a?(Hash)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Decorates only the keys listed in @only_keys via direct hash access.
|
|
252
|
+
# No iteration over the full hash, no type checking on non-target keys.
|
|
253
|
+
#
|
|
254
|
+
# @param current [Hash] current stats node
|
|
255
|
+
# @param filled_previous [Hash] previous stats node
|
|
256
|
+
# @param change_d [Integer] time delta in ms
|
|
257
|
+
def decorate_keys(current, filled_previous, change_d)
|
|
258
|
+
cache = @suffix_keys_cache
|
|
259
|
+
only = @only_keys
|
|
260
|
+
|
|
261
|
+
only.each do |key|
|
|
262
|
+
value = current[key]
|
|
99
263
|
|
|
100
264
|
next unless value.is_a?(Numeric)
|
|
101
265
|
|
|
@@ -109,21 +273,10 @@ module Karafka
|
|
|
109
273
|
next
|
|
110
274
|
end
|
|
111
275
|
|
|
112
|
-
# Inlined suffix_keys_for for reduced method call overhead
|
|
113
276
|
pair = cache[key] || (cache[key] = ["#{key}_fd".freeze, "#{key}_d".freeze].freeze)
|
|
114
277
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
pw[pw_size + 2] = pair[1]
|
|
118
|
-
pw[pw_size + 3] = result
|
|
119
|
-
pw_size += 4
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
# Apply collected writes for this hash level
|
|
123
|
-
i = pw_start
|
|
124
|
-
while i < pw_size
|
|
125
|
-
current[pw[i]] = pw[i + 1]
|
|
126
|
-
i += 2
|
|
278
|
+
current[pair[0]] = (result == 0) ? (filled_previous[pair[0]] || 0) + change_d : 0
|
|
279
|
+
current[pair[1]] = result
|
|
127
280
|
end
|
|
128
281
|
end
|
|
129
282
|
end
|
data/lib/karafka/core/version.rb
CHANGED
|
@@ -9,6 +9,43 @@ describe_current do
|
|
|
9
9
|
it { assert_equal id, event.id }
|
|
10
10
|
it { assert_equal payload, event.payload }
|
|
11
11
|
|
|
12
|
+
describe "when time is provided" do
|
|
13
|
+
subject(:timed_event) { described_class.new(id, payload, execution_time) }
|
|
14
|
+
|
|
15
|
+
let(:execution_time) { rand }
|
|
16
|
+
|
|
17
|
+
context "when payload is empty" do
|
|
18
|
+
let(:payload) { {} }
|
|
19
|
+
|
|
20
|
+
it "expect to return only time in payload" do
|
|
21
|
+
assert_equal({ time: execution_time }, timed_event.payload)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
context "when payload is non-empty" do
|
|
26
|
+
let(:payload) { { key: "value" } }
|
|
27
|
+
|
|
28
|
+
it "expect to merge time into payload" do
|
|
29
|
+
assert_equal({ key: "value", time: execution_time }, timed_event.payload)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe "#[] with time" do
|
|
34
|
+
let(:payload) { { key: "value" } }
|
|
35
|
+
|
|
36
|
+
it "expect to return time directly via #[] without triggering payload construction" do
|
|
37
|
+
assert_equal execution_time, timed_event[:time]
|
|
38
|
+
# We intentionally inspect the internal @payload ivar to ensure that #[] returns time
|
|
39
|
+
# without triggering the lazy payload memoization as a side effect.
|
|
40
|
+
assert_nil timed_event.instance_variable_get(:@payload)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "expect to still access raw payload keys" do
|
|
44
|
+
assert_equal "value", timed_event[:key]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
12
49
|
describe "#[]" do
|
|
13
50
|
context "when key is present" do
|
|
14
51
|
let(:payload) { { test: 1 } }
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
describe_current do
|
|
4
4
|
subject(:decorator) { described_class.new }
|
|
5
5
|
|
|
6
|
-
let(:
|
|
6
|
+
let(:emitted_stats1) do
|
|
7
7
|
{
|
|
8
8
|
"string" => "value1",
|
|
9
9
|
"float" => 10.4,
|
|
@@ -18,7 +18,7 @@ describe_current do
|
|
|
18
18
|
}
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
let(:
|
|
21
|
+
let(:emitted_stats2) do
|
|
22
22
|
{
|
|
23
23
|
"string" => "value2",
|
|
24
24
|
"float" => 10.8,
|
|
@@ -33,7 +33,7 @@ describe_current do
|
|
|
33
33
|
}
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
let(:
|
|
36
|
+
let(:emitted_stats3) do
|
|
37
37
|
{
|
|
38
38
|
"string" => "value3",
|
|
39
39
|
"float" => 11.8,
|
|
@@ -51,7 +51,7 @@ describe_current do
|
|
|
51
51
|
let(:broker_scope) { %w[nested brokers localhost:9092/2] }
|
|
52
52
|
|
|
53
53
|
context "when it is a first stats emit" do
|
|
54
|
-
subject(:decorated) { decorator.call(
|
|
54
|
+
subject(:decorated) { decorator.call(emitted_stats1) }
|
|
55
55
|
|
|
56
56
|
it { assert_equal "value1", decorated["string"] }
|
|
57
57
|
it { refute decorated.key?("string_d") }
|
|
@@ -63,8 +63,8 @@ describe_current do
|
|
|
63
63
|
|
|
64
64
|
context "when it is a second stats emit" do
|
|
65
65
|
subject(:decorated) do
|
|
66
|
-
decorator.call(
|
|
67
|
-
decorator.call(
|
|
66
|
+
decorator.call(emitted_stats1)
|
|
67
|
+
decorator.call(emitted_stats2)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
it { assert_equal "value2", decorated["string"] }
|
|
@@ -77,9 +77,9 @@ describe_current do
|
|
|
77
77
|
|
|
78
78
|
context "when it is a third stats emit" do
|
|
79
79
|
subject(:decorated) do
|
|
80
|
-
decorator.call(
|
|
81
|
-
decorator.call(
|
|
82
|
-
decorator.call(
|
|
80
|
+
decorator.call(emitted_stats1)
|
|
81
|
+
decorator.call(emitted_stats2)
|
|
82
|
+
decorator.call(emitted_stats3)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
it { assert_equal "value3", decorated["string"] }
|
|
@@ -97,11 +97,11 @@ describe_current do
|
|
|
97
97
|
|
|
98
98
|
context "when a broker is no longer present" do
|
|
99
99
|
subject(:decorated) do
|
|
100
|
-
decorator.call(
|
|
101
|
-
decorator.call(
|
|
100
|
+
decorator.call(emitted_stats1)
|
|
101
|
+
decorator.call(emitted_stats2)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
before {
|
|
104
|
+
before { emitted_stats2["nested"] = {} }
|
|
105
105
|
|
|
106
106
|
it { assert_equal "value2", decorated["string"] }
|
|
107
107
|
it { refute decorated.key?("string_d") }
|
|
@@ -117,11 +117,11 @@ describe_current do
|
|
|
117
117
|
|
|
118
118
|
context "when broker was introduced later on" do
|
|
119
119
|
subject(:decorated) do
|
|
120
|
-
decorator.call(
|
|
121
|
-
decorator.call(
|
|
120
|
+
decorator.call(emitted_stats1)
|
|
121
|
+
decorator.call(emitted_stats2)
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
before {
|
|
124
|
+
before { emitted_stats1["nested"] = {} }
|
|
125
125
|
|
|
126
126
|
it { assert_equal "value2", decorated["string"] }
|
|
127
127
|
it { refute decorated.key?("string_d") }
|
|
@@ -146,7 +146,7 @@ describe_current do
|
|
|
146
146
|
decorator.call(deep_copy.call)
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
let(:deep_copy) { -> { Marshal.load(Marshal.dump(
|
|
149
|
+
let(:deep_copy) { -> { Marshal.load(Marshal.dump(emitted_stats1)) } }
|
|
150
150
|
|
|
151
151
|
it { refute decorated.key?("string_d") }
|
|
152
152
|
it { refute decorated.key?("string_fd") }
|
|
@@ -171,7 +171,7 @@ describe_current do
|
|
|
171
171
|
decorator.call(deep_copy.call)
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
let(:deep_copy) { -> { Marshal.load(Marshal.dump(
|
|
174
|
+
let(:deep_copy) { -> { Marshal.load(Marshal.dump(emitted_stats1)) } }
|
|
175
175
|
|
|
176
176
|
it { refute decorated.key?("string_d") }
|
|
177
177
|
it { refute decorated.key?("string_fd") }
|
|
@@ -186,13 +186,13 @@ describe_current do
|
|
|
186
186
|
|
|
187
187
|
context "when a value type changed from non-numeric to numeric between emissions" do
|
|
188
188
|
subject(:decorated) do
|
|
189
|
-
decorator.call(
|
|
190
|
-
decorator.call(
|
|
189
|
+
decorator.call(emitted_stats1)
|
|
190
|
+
decorator.call(emitted_stats2)
|
|
191
191
|
end
|
|
192
192
|
|
|
193
193
|
before do
|
|
194
194
|
# In the first emission, txbytes is a string (unusual but defensive)
|
|
195
|
-
|
|
195
|
+
emitted_stats1["nested"]["brokers"]["localhost:9092/2"]["txbytes"] = "not_a_number"
|
|
196
196
|
end
|
|
197
197
|
|
|
198
198
|
# When previous value was non-numeric but current is numeric, no delta should be computed
|
|
@@ -206,7 +206,7 @@ describe_current do
|
|
|
206
206
|
subject(:decorator) { described_class.new(excluded_keys: %w[nested]) }
|
|
207
207
|
|
|
208
208
|
context "when it is a first stats emit" do
|
|
209
|
-
subject(:decorated) { decorator.call(
|
|
209
|
+
subject(:decorated) { decorator.call(emitted_stats1) }
|
|
210
210
|
|
|
211
211
|
it { assert_equal 0, decorated["float_d"] }
|
|
212
212
|
it { assert_equal 0, decorated["int_d"] }
|
|
@@ -222,8 +222,8 @@ describe_current do
|
|
|
222
222
|
|
|
223
223
|
context "when it is a second stats emit" do
|
|
224
224
|
subject(:decorated) do
|
|
225
|
-
decorator.call(
|
|
226
|
-
decorator.call(
|
|
225
|
+
decorator.call(emitted_stats1)
|
|
226
|
+
decorator.call(emitted_stats2)
|
|
227
227
|
end
|
|
228
228
|
|
|
229
229
|
it { assert_in_delta(0.4, decorated["float_d"].round(10)) }
|
|
@@ -241,8 +241,8 @@ describe_current do
|
|
|
241
241
|
let(:decorator) { described_class.new(excluded_keys: %w[int]) }
|
|
242
242
|
|
|
243
243
|
subject(:decorated) do
|
|
244
|
-
decorator.call(
|
|
245
|
-
decorator.call(
|
|
244
|
+
decorator.call(emitted_stats1)
|
|
245
|
+
decorator.call(emitted_stats2)
|
|
246
246
|
end
|
|
247
247
|
|
|
248
248
|
it { assert_in_delta(0.4, decorated["float_d"].round(10)) }
|
|
@@ -256,23 +256,242 @@ describe_current do
|
|
|
256
256
|
let(:decorator) { described_class.new }
|
|
257
257
|
|
|
258
258
|
subject(:decorated) do
|
|
259
|
-
decorator.call(
|
|
260
|
-
decorator.call(
|
|
259
|
+
decorator.call(emitted_stats1)
|
|
260
|
+
decorator.call(emitted_stats2)
|
|
261
261
|
end
|
|
262
262
|
|
|
263
263
|
it { assert_equal 18, decorated["int_d"] }
|
|
264
264
|
it { assert_equal 30, decorated.dig(*broker_scope)["txbytes_d"] }
|
|
265
265
|
end
|
|
266
266
|
|
|
267
|
+
context "when only_keys are configured" do
|
|
268
|
+
let(:decorator) { described_class.new(only_keys: %w[int]) }
|
|
269
|
+
|
|
270
|
+
context "when it is a first stats emit" do
|
|
271
|
+
subject(:decorated) { decorator.call(emitted_stats1) }
|
|
272
|
+
|
|
273
|
+
it { assert_equal 0, decorated["int_d"] }
|
|
274
|
+
it { assert_predicate decorated, :frozen? }
|
|
275
|
+
|
|
276
|
+
it "does not decorate non-listed numeric keys" do
|
|
277
|
+
refute decorated.key?("float_d")
|
|
278
|
+
refute decorated.key?("float_fd")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "preserves non-listed numeric values" do
|
|
282
|
+
assert_in_delta 10.4, decorated["float"]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it "preserves string values" do
|
|
286
|
+
assert_equal "value1", decorated["string"]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
context "when it is a second stats emit" do
|
|
291
|
+
subject(:decorated) do
|
|
292
|
+
decorator.call(emitted_stats1)
|
|
293
|
+
decorator.call(emitted_stats2)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
it { assert_equal 18, decorated["int_d"] }
|
|
297
|
+
it { assert_predicate decorated, :frozen? }
|
|
298
|
+
|
|
299
|
+
it "does not decorate non-listed numeric keys" do
|
|
300
|
+
refute decorated.key?("float_d")
|
|
301
|
+
refute decorated.key?("float_fd")
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
context "when only_keys targets a nested value" do
|
|
306
|
+
let(:decorator) { described_class.new(only_keys: %w[txbytes]) }
|
|
307
|
+
|
|
308
|
+
subject(:decorated) do
|
|
309
|
+
decorator.call(emitted_stats1)
|
|
310
|
+
decorator.call(emitted_stats2)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it "decorates matching keys in nested hashes" do
|
|
314
|
+
assert_equal 30, decorated.dig(*broker_scope)["txbytes_d"]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it "does not decorate non-listed root keys" do
|
|
318
|
+
refute decorated.key?("int_d")
|
|
319
|
+
refute decorated.key?("float_d")
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
context "when only_keys and excluded_keys are combined" do
|
|
325
|
+
let(:decorator) { described_class.new(excluded_keys: %w[nested], only_keys: %w[int]) }
|
|
326
|
+
|
|
327
|
+
subject(:decorated) do
|
|
328
|
+
decorator.call(emitted_stats1)
|
|
329
|
+
decorator.call(emitted_stats2)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
it { assert_equal 18, decorated["int_d"] }
|
|
333
|
+
it { assert_predicate decorated, :frozen? }
|
|
334
|
+
|
|
335
|
+
it "does not decorate non-listed keys" do
|
|
336
|
+
refute decorated.key?("float_d")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
it "does not recurse into excluded subtrees" do
|
|
340
|
+
refute decorated.dig(*broker_scope).key?("txbytes_d")
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
context "when only_keys value remains unchanged over time" do
|
|
345
|
+
subject(:decorated) do
|
|
346
|
+
decorator.call(deep_copy.call)
|
|
347
|
+
decorator.call(deep_copy.call)
|
|
348
|
+
sleep(0.01)
|
|
349
|
+
decorator.call(deep_copy.call)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
let(:decorator) { described_class.new(only_keys: %w[int]) }
|
|
353
|
+
let(:deep_copy) { -> { Marshal.load(Marshal.dump(emitted_stats1)) } }
|
|
354
|
+
|
|
355
|
+
it { assert_equal 0, decorated["int_d"] }
|
|
356
|
+
it { assert_in_delta 10, decorated["int_fd"], 5 }
|
|
357
|
+
|
|
358
|
+
it "does not decorate non-listed keys" do
|
|
359
|
+
refute decorated.key?("float_d")
|
|
360
|
+
refute decorated.key?("float_fd")
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
context "when only_keys is used with librdkafka-like structure" do
|
|
365
|
+
let(:decorator) { described_class.new(only_keys: %w[rxmsgs txbytes consumer_lag]) }
|
|
366
|
+
|
|
367
|
+
let(:rdkafka_stats1) do
|
|
368
|
+
{
|
|
369
|
+
"rxmsgs" => 100,
|
|
370
|
+
"name" => "consumer-1",
|
|
371
|
+
"brokers" => {
|
|
372
|
+
"localhost:9092/0" => {
|
|
373
|
+
"txbytes" => 500,
|
|
374
|
+
"rxbytes" => 300,
|
|
375
|
+
"nodeid" => 0
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
"topics" => {
|
|
379
|
+
"events" => {
|
|
380
|
+
"age" => 1000,
|
|
381
|
+
"partitions" => {
|
|
382
|
+
"0" => {
|
|
383
|
+
"consumer_lag" => 50,
|
|
384
|
+
"hi_offset" => 1000,
|
|
385
|
+
"txmsgs" => 200
|
|
386
|
+
},
|
|
387
|
+
"1" => {
|
|
388
|
+
"consumer_lag" => 30,
|
|
389
|
+
"hi_offset" => 800,
|
|
390
|
+
"txmsgs" => 150
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
"cgrp" => {
|
|
396
|
+
"stateage" => 5000,
|
|
397
|
+
"rebalance_cnt" => 2
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
let(:rdkafka_stats2) do
|
|
403
|
+
{
|
|
404
|
+
"rxmsgs" => 150,
|
|
405
|
+
"name" => "consumer-1",
|
|
406
|
+
"brokers" => {
|
|
407
|
+
"localhost:9092/0" => {
|
|
408
|
+
"txbytes" => 700,
|
|
409
|
+
"rxbytes" => 500,
|
|
410
|
+
"nodeid" => 0
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
"topics" => {
|
|
414
|
+
"events" => {
|
|
415
|
+
"age" => 2000,
|
|
416
|
+
"partitions" => {
|
|
417
|
+
"0" => {
|
|
418
|
+
"consumer_lag" => 40,
|
|
419
|
+
"hi_offset" => 1100,
|
|
420
|
+
"txmsgs" => 250
|
|
421
|
+
},
|
|
422
|
+
"1" => {
|
|
423
|
+
"consumer_lag" => 25,
|
|
424
|
+
"hi_offset" => 900,
|
|
425
|
+
"txmsgs" => 200
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
"cgrp" => {
|
|
431
|
+
"stateage" => 6000,
|
|
432
|
+
"rebalance_cnt" => 2
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
subject(:decorated) do
|
|
438
|
+
decorator.call(rdkafka_stats1)
|
|
439
|
+
decorator.call(rdkafka_stats2)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
it "decorates root only_keys" do
|
|
443
|
+
assert_equal 50, decorated["rxmsgs_d"]
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
it "does not decorate non-listed root keys" do
|
|
447
|
+
refute decorated.key?("name_d")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
it "decorates broker only_keys" do
|
|
451
|
+
assert_equal 200, decorated["brokers"]["localhost:9092/0"]["txbytes_d"]
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
it "does not decorate non-listed broker keys" do
|
|
455
|
+
refute decorated["brokers"]["localhost:9092/0"].key?("rxbytes_d")
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
it "decorates partition only_keys" do
|
|
459
|
+
assert_equal(-10, decorated.dig("topics", "events", "partitions", "0")["consumer_lag_d"])
|
|
460
|
+
assert_equal(-5, decorated.dig("topics", "events", "partitions", "1")["consumer_lag_d"])
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
it "does not decorate non-listed partition keys" do
|
|
464
|
+
refute decorated.dig("topics", "events", "partitions", "0").key?("txmsgs_d")
|
|
465
|
+
refute decorated.dig("topics", "events", "partitions", "0").key?("hi_offset_d")
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it "does not decorate non-listed topic keys" do
|
|
469
|
+
refute decorated.dig("topics", "events").key?("age_d")
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
it { assert_predicate decorated, :frozen? }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
context "when only_keys previous value type changed to non-numeric" do
|
|
476
|
+
let(:decorator) { described_class.new(only_keys: %w[val]) }
|
|
477
|
+
|
|
478
|
+
subject(:decorated) do
|
|
479
|
+
decorator.call({ "val" => "not_numeric" })
|
|
480
|
+
decorator.call({ "val" => 10 })
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
it { refute decorated.key?("val_d") }
|
|
484
|
+
end
|
|
485
|
+
|
|
267
486
|
context "when a value type changed from numeric to non-numeric between emissions" do
|
|
268
487
|
subject(:decorated) do
|
|
269
|
-
decorator.call(
|
|
270
|
-
decorator.call(
|
|
488
|
+
decorator.call(emitted_stats1)
|
|
489
|
+
decorator.call(emitted_stats2)
|
|
271
490
|
end
|
|
272
491
|
|
|
273
492
|
before do
|
|
274
493
|
# In the second emission, txbytes changed to a string
|
|
275
|
-
|
|
494
|
+
emitted_stats2["nested"]["brokers"]["localhost:9092/2"]["txbytes"] = "not_a_number"
|
|
276
495
|
end
|
|
277
496
|
|
|
278
497
|
# Non-numeric values are never decorated
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: karafka-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.5.
|
|
4
|
+
version: 2.5.11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Maciej Mensfeld
|
|
@@ -144,7 +144,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
144
144
|
- !ruby/object:Gem::Version
|
|
145
145
|
version: '0'
|
|
146
146
|
requirements: []
|
|
147
|
-
rubygems_version: 4.0.
|
|
147
|
+
rubygems_version: 4.0.6
|
|
148
148
|
specification_version: 4
|
|
149
149
|
summary: Karafka ecosystem core modules
|
|
150
150
|
test_files: []
|