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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fe7a4974267a2a3b2b200e0763a13042d98695c0586931d00d0016087bc5dfe2
4
- data.tar.gz: a5bdd9235a999028ad120d79c2986e05986ee1b9df8a5de6b813fff634bc5619
3
+ metadata.gz: 6ada2e9b6d4c3d9adfe4aa595f9208198b60eee63bed805501a48d9095da1d90
4
+ data.tar.gz: 77fb9a1be85a9f120057e784385cfb667ec5e65e0461a2fb059fc4e76e386a0a
5
5
  SHA512:
6
- metadata.gz: be10ba6481782906bbb076ea69ab9aa05c80f4c295ce82c2faacc53ee27a8ebd01e4c7a200088205058fda7e7efef494d71ac96492d3cf074f579ea331597148
7
- data.tar.gz: a51a53327518e282ab42461fccc3aea78407de1418d97376a64b25dda687b616a54f0c6639b542d0c104534876b1c34d9f7f1434486300c40708f129f03ad267
6
+ metadata.gz: 4f22a703bb815c99259ab70f53adb090b8100dcf7bfb91785706fbd2a91a8bba02a12ef5412d93e4459cfc4ac51e9bbce4f4f1ead392317255f588b823eeb356
7
+ data.tar.gz: 33cd8fa76ca78714edbcc9ddb052d345ddeb0e63bc569e718ff47feef7db273c52a5288801c35d88c4248b91904a442537b827ea749c72a09fb7724b85bf2bf7
@@ -14,7 +14,7 @@ permissions:
14
14
  contents: read
15
15
 
16
16
  jobs:
17
- specs:
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@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0
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@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0
72
+ uses: ruby/setup-ruby@eab2afb99481ca09a4e91171a8e0aee0e89bfedd # v1.296.0
73
73
  with:
74
- ruby-version: '4.0.1'
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@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0
89
+ uses: ruby/setup-ruby@eab2afb99481ca09a4e91171a8e0aee0e89bfedd # v1.296.0
90
90
  with:
91
- ruby-version: '4.0.1'
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@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
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
- - specs
119
+ - tests
120
120
  - yard-lint
121
121
  - lostconf
122
122
  steps:
@@ -24,7 +24,7 @@ jobs:
24
24
  fetch-depth: 0
25
25
 
26
26
  - name: Set up Ruby
27
- uses: ruby/setup-ruby@09a7688d3b55cf0e976497ff046b70949eeaccfd # v1.288.0
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@1c162a739e8b4cb21a676e97b087e8268d8fc40b # v1.1.2
35
+ - uses: rubygems/release-gem@e9a6361a0b14562539327c2a02373edc56dd3169 # v1.1.4
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.1
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- karafka-core (2.5.10)
4
+ karafka-core (2.5.11)
5
5
  karafka-rdkafka (>= 0.20.0)
6
6
  logger (>= 1.6.0)
7
7
 
@@ -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
- def initialize(node_name, nestings = ->(_) {})
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.include?(reader_name) : true
193
- @local_defs << reader_name
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
- private_constant :DIG_MISS
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
- current = data
193
+ case keys.length
194
+ when 1
195
+ key = keys[0]
187
196
 
188
- keys.each do |nesting|
189
- return DIG_MISS unless current.key?(nesting)
197
+ return DIG_MISS unless data.key?(key)
190
198
 
191
- current = current[nesting]
192
- end
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
- current
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, :payload
8
+ attr_reader :id
9
9
 
10
10
  # @param id [String, Symbol] id of the event
11
11
  # @param payload [Hash] event payload
12
- def initialize(id, payload)
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
- @payload = payload
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
- @payload.fetch(name)
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
- final_payload = build_payload(payload, time)
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 event_id [String]
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(event_id, event, assigned_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(@events_methods_map[event_id], event)
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 emited stats, so we get also
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
- def initialize(excluded_keys: [])
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 emited_stats [Hash] original emited statistics
45
- # @return [Hash] emited statistics extended with the diff data
46
- # @note We modify the emited statistics, instead of creating new. Since we don't expose
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(emited_stats)
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
- emited_stats,
56
- [],
57
- 0,
64
+ emitted_stats,
58
65
  change_d
59
66
  )
60
67
 
61
- @previous = emited_stats
68
+ @previous = emitted_stats
62
69
  @previous_at = current_at
63
70
 
64
- emited_stats.freeze
71
+ emitted_stats.freeze
65
72
  end
66
73
 
67
74
  private
68
75
 
69
- # Calculates the diff of the provided values, appends delta and freeze duration keys,
70
- # and modifies in place the emited statistics.
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
- # Uses `each_pair` with a per-call pending-writes buffer instead of `current.keys.each`
73
- # to avoid allocating a new Array for every Hash node in the statistics tree. At scale
74
- # (thousands of partitions), this reduces allocations from tens of thousands to one per call.
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
- # The append and suffix_keys_for logic is inlined to reduce method call overhead
77
- # (from ~915k method calls to ~39k at 6400 partitions).
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, pw, pw_start, change_d)
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.each_pair do |key, value|
115
+ current.keys.each do |key|
93
116
  next if excluded&.key?(key)
94
117
 
95
- if value.is_a?(Hash)
96
- diff(filled_previous[key], value, pw, pw_size, change_d)
97
- next
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
- pw[pw_size] = pair[0]
116
- pw[pw_size + 1] = (result == 0) ? (filled_previous[pair[0]] || 0) + change_d : 0
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
@@ -4,6 +4,6 @@ module Karafka
4
4
  module Core
5
5
  # Current Karafka::Core version
6
6
  # We follow the versioning schema of given Karafka version
7
- VERSION = "2.5.10"
7
+ VERSION = "2.5.11"
8
8
  end
9
9
  end
@@ -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(:emited_stats1) do
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(:emited_stats2) do
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(:emited_stats3) do
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(emited_stats1) }
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(emited_stats1)
67
- decorator.call(emited_stats2)
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(emited_stats1)
81
- decorator.call(emited_stats2)
82
- decorator.call(emited_stats3)
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(emited_stats1)
101
- decorator.call(emited_stats2)
100
+ decorator.call(emitted_stats1)
101
+ decorator.call(emitted_stats2)
102
102
  end
103
103
 
104
- before { emited_stats2["nested"] = {} }
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(emited_stats1)
121
- decorator.call(emited_stats2)
120
+ decorator.call(emitted_stats1)
121
+ decorator.call(emitted_stats2)
122
122
  end
123
123
 
124
- before { emited_stats1["nested"] = {} }
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(emited_stats1)) } }
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(emited_stats1)) } }
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(emited_stats1)
190
- decorator.call(emited_stats2)
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
- emited_stats1["nested"]["brokers"]["localhost:9092/2"]["txbytes"] = "not_a_number"
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(emited_stats1) }
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(emited_stats1)
226
- decorator.call(emited_stats2)
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(emited_stats1)
245
- decorator.call(emited_stats2)
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(emited_stats1)
260
- decorator.call(emited_stats2)
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(emited_stats1)
270
- decorator.call(emited_stats2)
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
- emited_stats2["nested"]["brokers"]["localhost:9092/2"]["txbytes"] = "not_a_number"
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.10
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.3
147
+ rubygems_version: 4.0.6
148
148
  specification_version: 4
149
149
  summary: Karafka ecosystem core modules
150
150
  test_files: []