karafka-core 2.6.1 → 2.6.2

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: 2d9f6ba569f0c3a911cbe3ce80c545f3069b5aa2560782cf88bd11c97baa0432
4
- data.tar.gz: 54f274da23e99841809f884ea871ce9a55705d76d3c4e5c40b0ef2ffbe0ae75a
3
+ metadata.gz: b5f044e02de2bc5b5e41fb033c9e4f0a4e66f45890615053d3c1fe1c33ec8058
4
+ data.tar.gz: 5ad2604a98478db69950c2a94064dcd20d6ceafa6d3eab03d907700b6cadf3eb
5
5
  SHA512:
6
- metadata.gz: 3a6c6ba64216479cebb2c4f574dff194ae6665189f382b0a60193c5b4ec3ab4d55f4ff97d7fb3fc90800f105a5284da27493b5694adff6e1a6c0738fa5795f65
7
- data.tar.gz: 8ea0eddcd9ae95603041f8a74a1a26311cd9aa13744759dd6e7a699eb6ce682fd89596ef85e77ee2fd280d4aa3eca14d8f2ddd7a75dc322fe6e7bb33903ac7b6
6
+ metadata.gz: 0cd334edd644f023a15d87bd35cfe535ca0edcddb39f37630c283801bed26821f3cfa6bfcb478bdd987fae0a549bbdbda56cb4258430f8f676636c368afcddcc
7
+ data.tar.gz: 44a3eba562af3a0315391f362b28684c54f543110f6f76dd6e99e576655c749866a737f1235cf1971992c0274ed3a1da21db3cf83b07cc83ec28e76ef8d17424
@@ -29,7 +29,7 @@ jobs:
29
29
  - ruby: '4.0'
30
30
  coverage: 'true'
31
31
  steps:
32
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
32
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
33
33
  with:
34
34
  fetch-depth: 0
35
35
 
@@ -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@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
40
+ uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
41
41
  with:
42
42
  ruby-version: ${{matrix.ruby}}
43
43
  bundler: 'latest'
@@ -65,11 +65,11 @@ jobs:
65
65
  env:
66
66
  BUNDLE_GEMFILE: Gemfile.lint
67
67
  steps:
68
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
68
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
69
69
  with:
70
70
  fetch-depth: 0
71
71
  - name: Set up Ruby
72
- uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
72
+ uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
73
73
  with:
74
74
  ruby-version: '4.0.5'
75
75
  bundler-cache: true
@@ -82,11 +82,11 @@ jobs:
82
82
  env:
83
83
  BUNDLE_GEMFILE: Gemfile.lint
84
84
  steps:
85
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
85
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
86
86
  with:
87
87
  fetch-depth: 0
88
88
  - name: Set up Ruby
89
- uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
89
+ uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
90
90
  with:
91
91
  ruby-version: '4.0.5'
92
92
  bundler-cache: true
@@ -97,7 +97,7 @@ jobs:
97
97
  timeout-minutes: 5
98
98
  runs-on: ubuntu-latest
99
99
  steps:
100
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
100
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
101
101
  with:
102
102
  fetch-depth: 0
103
103
  - name: Set up Node.js
@@ -19,12 +19,12 @@ jobs:
19
19
  id-token: write
20
20
 
21
21
  steps:
22
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
22
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
23
23
  with:
24
24
  fetch-depth: 0
25
25
 
26
26
  - name: Set up Ruby
27
- uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
27
+ uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.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@6317d8d1f7e28c24d28f6eff169ea854948bd9f7 # v1.2.0
35
+ - uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
@@ -7,7 +7,7 @@ jobs:
7
7
  verify_action_pins:
8
8
  runs-on: ubuntu-latest
9
9
  steps:
10
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
10
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
11
11
  - name: Check SHA pins
12
12
  run: |
13
13
  if grep -E -r "uses: .*/.*@(v[0-9]+|main|master)($|[[:space:]]|$)" --include="*.yml" --include="*.yaml" .github/workflows/ | grep -v "#"; then
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Karafka Core Changelog
2
2
 
3
+ ## 2.6.2 (2026-06-29)
4
+ - [Enhancement] Document that a leaf's `default` value is intentionally shared by reference across the class template and every config instance produced by `Configurable::Node#deep_dup`. This uniform rule (the leaf is shallow-copied) is what lets a shared service object passed as a default (e.g. a logger) keep its identity across all configs; the flip side is that an in-place mutation of a mutable container default (`config.list << :x`) is visible on every instance. Callers that need a per-instance mutable default should assign it inside a `configure` block or dup it themselves rather than relying on a mutable `default:` (e.g. `default: []`). Adds characterization tests covering the shared-default behavior.
5
+ - [Fix] `require "pathname"` explicitly in `lib/karafka-core.rb` (with the other top-level requires). `Karafka::Core.gem_root` returns a `Pathname`, but the gem never required `pathname` -- it only worked because Bundler (or another gem) happened to load it first. In an environment where nothing else loads it, `gem_root` raised `NameError: uninitialized constant Pathname`.
6
+ - [Enhancement] Document the `virtual` rule result contract: a rule must return a freshly built `Array` of `[path, message]` error pairs on every call. `Contract#call` takes ownership of that array and prepends the current scope onto each pair in place (avoiding a per-error allocation), so returning a memoized, shared or frozen array is unsupported -- the in-place scoping would accumulate the scope prefix across validations or raise `FrozenError`. Adds characterization tests for the supported and unsupported patterns.
7
+ - [Fix] `Configurable::Node#register` raises the documented "already registered" `ArgumentError` for a name already used by an unread lazy-with-constructor setting. The duplicate guard only checked `@configs_refs`, but a lazy setting with a constructor is absent from it until first read, so `register` silently overwrote it; it now checks the defined children.
8
+ - [Fix] `Contractable::Contract.nested` now pops its path in an `ensure`. If the block raised while the contract was being defined and the caller rescued it, the path stayed on the nesting stack and was prefixed onto every rule defined afterwards.
9
+ - [Fix] `Contract#call` no longer raises `NoMethodError` when validating a non-Hash root with a 1-key or 2-key rule path; it reports the path as missing, consistent with the 3+-key path (and the non-Hash intermediate handling added in 2.6.1).
10
+ - [Fix] Honor `excluded_keys` containing `"cgrp"` in `StatisticsDecorator` `only_keys` mode. The `cgrp` branch of the structure-aware fast path lacked the exclusion guard that the `brokers` and `topics` branches have, so excluding the consumer-group subtree still decorated it (inconsistent with the full-decoration path).
11
+ - [Fix] Guard the patched rdkafka error callback against a null client pointer. librdkafka can invoke the error callback with a NULL `rd_kafka_t` (e.g. very early in client construction); calling `rd_kafka_name` on it dereferenced the null pointer and could segfault the process. Mirrors the upstream `ErrorCallback`.
12
+ - [Fix] Resolve fatal errors in the patched rdkafka error callback. `ERR__FATAL` is only a generic marker, so the callback now fetches the real underlying error code and description via `RdkafkaError.build_fatal` (`rd_kafka_fatal_error`) instead of reporting the generic fatal code. Mirrors the upstream `ErrorCallback`.
13
+ - [Fix] A lazy setting declared without a constructor (`setting(:x, lazy: true)`) no longer raises when its accessor is read. `lazy: true` only makes sense together with a constructor to (re)evaluate; without one there is nothing to evaluate, so such a setting now behaves like a regular setting backed by its default. Previously reading it raised `NoMethodError` — `nil.arity` through the dynamic accessor for a falsy default, or a missing accessor for a truthy default.
14
+ - [Fix] `Contract#call` no longer raises `NoMethodError` when a virtual rule returns `false`. A virtual rule now signals "no errors" with any non-Array result (`true`/`false`/`nil`); only an `Array` of error pairs is collected. Previously a `false` return reached `false.each` (a `nil` return was already tolerated).
15
+ - [Fix] Manage `CallbacksManager` callbacks copy-on-write: `add`/`delete` rebuild and atomically swap an immutable values snapshot under a mutex, and `#call` iterates that snapshot directly. The previous values cache (introduced in 2.5.11) was lazily invalidated from within `#call`, which could not be done atomically against a concurrent `add`/`delete`, so a callback racing with dispatch could be silently lost — a removed one kept firing forever, or a newly added one never fired. The copy-on-write read takes no lock and allocates nothing per call, so the race is fixed without reintroducing a per-call `#values` allocation. librdkafka statistics/error callbacks fire from a background thread while callbacks are registered/unregistered, so this was reachable in practice.
16
+ - [Fix] Manage `Notifications` subscriptions copy-on-write (`#subscribe`, `#unsubscribe` and `#clear` replace the per-event listener array instead of mutating it in place) so a listener that unsubscribes itself (or another) from within its own handler no longer causes the listener following it to be silently skipped, and concurrent subscribe/unsubscribe during dispatch is safe. Dispatch keeps iterating the live array directly, so there is no per-notification allocation on the hot path.
17
+ - [Fix] Report a freeze duration (`_fd`) of `0` for statistics keys that are newly introduced in an emission (e.g. a broker or partition that appears mid-stream) instead of the elapsed time since the previous emission. A key that did not exist in the prior emission could not have been "frozen" for any duration, so accumulating the inter-emission gap was incorrect and also made the related `StatisticsDecorator` spec flaky on slow CIs (`_fd` depended on the wall-clock gap between the two emissions).
18
+ - [Fix] Make assigning a setting on a frozen `Configurable::Node` atomic. The ivar-backed writer evaluated `@configs_refs[name] = value` before `instance_variable_set`, so a frozen node mutated the canonical store and only then raised `FrozenError`, leaving the store and the ivar-backed reader permanently out of sync. It now raises before touching any state.
19
+ - [Fix] `Configurable::Node#to_h` now evaluates a setting's constructor with its default (arity-aware, matching `#compile`) instead of calling it with no arguments. The documented `->(default) { ... }` constructor form previously raised `ArgumentError: wrong number of arguments` from `#to_h` whenever the value was not yet in the config store (e.g. `#to_h` on an unconfigured instance, or an unread lazy setting).
20
+ - [Fix] Honor `excluded_keys` inside `StatisticsDecorator` `only_keys` decoration. A key listed in both `only_keys` and `excluded_keys` was still decorated because the direct-access decoration loop never consulted `excluded_keys`; exclusion now wins, matching the full-decoration path.
21
+ - [Fix] Strip the tests/specs root directory as an anchored prefix (`sub(/\A.../)`) instead of a global `gsub` in `MinitestLocator` and `RSpecLocator`. When the root directory string recurred later in a test/spec file path, the global replace removed every occurrence and corrupted the derived subject class path; only the leading prefix is now removed.
22
+
3
23
  ## 2.6.1 (2026-06-15)
4
24
  - [Enhancement] Speed up `Contract#call` by ~1.25x for minimal and ~1.4x for fully populated data: resolve rule paths with a single `Hash#fetch` per level instead of `key?` + `[]`, inline the per-rule type dispatch into the rules loop, and compare the dig sentinel via `#equal?` so `#==` is never dispatched to the validated (user-provided) values. This is the per-message validation path in WaterDrop producers.
5
25
  - [Fix] `Contract#call` with rule paths of 3+ keys no longer raises `NoMethodError` when an intermediate value is not a `Hash` and reports the path as missing instead, consistent with the 2-key path behavior.
@@ -33,6 +53,7 @@
33
53
  - [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).
34
54
  - [Enhancement] Reorder `StatisticsDecorator#diff` type checks to test `Numeric` before `Hash`, matching the ~80% numeric value distribution in librdkafka statistics.
35
55
  - [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.
56
+ - [Enhancement] Cache `Tags#to_a` values array and invalidate on `add`/`delete`/`clear` to avoid allocating a new Array and running `uniq` on every call, yielding ~7x faster reads at 5 tags and ~28x faster at 20 tags.
36
57
 
37
58
  ## 2.5.10 (2026-03-02)
38
59
  - [Enhancement] Introduce `MinitestLocator` helper for minitest/spec subject class auto-discovery from test file paths.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- karafka-core (2.6.1)
4
+ karafka-core (2.6.2)
5
5
  karafka-rdkafka (>= 0.20.0)
6
6
  logger (>= 1.6.0)
7
7
 
@@ -116,7 +116,10 @@ module Karafka
116
116
  result = if @configs_refs.key?(value.node_name)
117
117
  @configs_refs[value.node_name]
118
118
  elsif value.constructor
119
- value.constructor.call
119
+ # Use the arity-aware helper (same as `#compile`) so a `->(default) { ... }`
120
+ # constructor receives its default instead of being called with no arguments,
121
+ # which would raise `ArgumentError: wrong number of arguments`.
122
+ call_constructor(value)
120
123
  elsif value.default
121
124
  value.default
122
125
  end
@@ -144,6 +147,17 @@ module Karafka
144
147
  # After inheritance we need to reload the state so the leafs are recompiled again
145
148
  value = value.dup
146
149
  value.compiled = false
150
+
151
+ # `Struct#dup` is intentionally shallow here: the leaf's `default` value is shared by
152
+ # reference across the class template and every config instance produced by
153
+ # `deep_dup`. This is the contract -- one uniform rule for all default types -- and it
154
+ # is what lets a shared service object passed as a default (e.g. a logger) keep its
155
+ # identity across all configs instead of being cloned per instance. The flip side is
156
+ # that an in-place mutation of a mutable container default (e.g. `config.list << :x`)
157
+ # is visible on every other instance and on the template. A caller that needs a
158
+ # per-instance mutable default should not rely on a mutable `default:` (e.g.
159
+ # `default: []`): assign the value inside a `configure` block, or dup it themselves,
160
+ # so each instance owns its own copy.
147
161
  value
148
162
  else
149
163
  value.deep_dup
@@ -175,7 +189,12 @@ module Karafka
175
189
 
176
190
  prevent_reserved_names!(name)
177
191
 
178
- raise ArgumentError, "#{name} is already registered" if @configs_refs.key?(name)
192
+ # Check the defined children, not just @configs_refs: a lazy setting with a constructor
193
+ # is not written to @configs_refs until first read, so a `@configs_refs.key?` guard
194
+ # alone would silently overwrite it instead of raising the documented error.
195
+ if @children.any? { |child| child.node_name == name }
196
+ raise ArgumentError, "#{name} is already registered"
197
+ end
179
198
 
180
199
  leaf = Leaf.new(name, value, nil, true, false)
181
200
  @children << leaf
@@ -190,7 +209,12 @@ module Karafka
190
209
  # Do not redefine something that was already set during compilation
191
210
  # This will allow us to reconfigure things and skip override with defaults
192
211
  skippable = @configs_refs.key?(value.node_name) || (value.is_a?(Leaf) && value.compiled?)
193
- lazy_leaf = value.is_a?(Leaf) && value.lazy?
212
+ # A leaf is only treated as lazy (a dynamically (re)evaluated accessor) when it
213
+ # actually has a constructor to evaluate. `lazy: true` without a constructor has
214
+ # nothing to evaluate, so it behaves like a regular setting backed by its default.
215
+ # Otherwise the lazy path builds a dynamic accessor that calls `constructor.arity`
216
+ # on a `nil` constructor and crashes on first read.
217
+ lazy_leaf = value.is_a?(Leaf) && value.lazy? && !value.constructor.nil?
194
218
 
195
219
  # Do not create accessor for leafs that are lazy as they will get a custom method
196
220
  # created instead
@@ -295,7 +319,8 @@ module Karafka
295
319
  ivar_name = :"@#{reader_name}"
296
320
 
297
321
  define_singleton_method(:"#{reader_name}=") do |new_value|
298
- instance_variable_set(ivar_name, @configs_refs[reader_name] = new_value)
322
+ instance_variable_set(ivar_name, new_value)
323
+ @configs_refs[reader_name] = new_value
299
324
  end
300
325
  else
301
326
  define_singleton_method(:"#{reader_name}=") do |new_value|
@@ -40,8 +40,14 @@ module Karafka
40
40
  def nested(path, &)
41
41
  init_accu
42
42
  @nested << path
43
- instance_eval(&)
44
- @nested.pop
43
+ begin
44
+ instance_eval(&)
45
+ ensure
46
+ # Always pop, even if the block raises. Otherwise a rescued exception during
47
+ # contract definition would leave `path` on @nested and prefix it onto every rule
48
+ # defined afterwards.
49
+ @nested.pop
50
+ end
45
51
  end
46
52
 
47
53
  # Defines a rule for a required field (required means, that will automatically create an
@@ -61,10 +67,19 @@ module Karafka
61
67
  @rules << Rule.new(@nested + keys, :optional, block).freeze
62
68
  end
63
69
 
64
- # @param block [Proc] validation rule
70
+ # Defines a virtual rule that validates the whole data rather than a single key. Unlike
71
+ # `required`/`optional`, the block receives the full data and returns its own errors.
72
+ #
73
+ # @param block [Proc] validation rule, called with `(data, errors, contract)`. It must
74
+ # return either a non-Array (`true`/`nil`/`false`) for "no errors", or an `Array` of
75
+ # `[path, message]` error pairs (where `path` is itself an `Array` of symbols).
65
76
  #
66
- # @note Virtual rules have different result expectations. Please see contracts or specs for
67
- # details.
77
+ # @note The returned error `Array` is owned by the contract: `#call` prepends the current
78
+ # scope onto each pair in place and collects them, so a rule must return a freshly
79
+ # built `Array` on every call. Returning a memoized, shared or frozen `Array` is not
80
+ # supported -- in-place scoping would accumulate the prefix across validations (or
81
+ # raise `FrozenError`). Build the result in the block (e.g. `[[%i[id], :invalid]]`)
82
+ # rather than returning a constant.
68
83
  def virtual(&block)
69
84
  init_accu
70
85
  @rules << Rule.new([], :virtual, block).freeze
@@ -94,19 +109,32 @@ module Karafka
94
109
  def call(data, scope: EMPTY_ARRAY)
95
110
  errors = []
96
111
 
112
+ # A non-Hash root has no keys to dig; resolve it once here so #dig stays on its lean
113
+ # fast path and is only invoked with a Hash. Non-Hash data makes every required rule
114
+ # report missing, consistent with the non-Hash intermediate handling inside #dig.
115
+ data_is_hash = data.is_a?(Hash)
116
+
97
117
  self.class.rules.each do |rule|
98
118
  if rule.type == :virtual
99
119
  result = rule.validator.call(data, errors, self)
100
120
 
101
- next if result == true
102
-
103
- result&.each do |sub_result|
121
+ # A virtual rule signals "no errors" with any non-Array result (true, but also a
122
+ # falsy `nil`/`false` returned by e.g. `condition && [[...], :err]`). Only an Array
123
+ # of error pairs is iterated; previously a `false` return reached `false.each` and
124
+ # raised NoMethodError (a `nil` return was already tolerated by the safe navigation).
125
+ next unless result.is_a?(Array)
126
+
127
+ # Apply the scope prefix in place on the rule's returned pairs and collect them
128
+ # directly. Per the `virtual` contract the rule hands back a freshly built Array
129
+ # each call (see `DSL#virtual`), so mutating it here is safe and avoids allocating a
130
+ # new pair per error.
131
+ result.each do |sub_result|
104
132
  sub_result[0] = scope + sub_result[0]
105
133
  end
106
134
 
107
135
  errors.push(*result)
108
136
  else
109
- for_checking = dig(data, rule.path)
137
+ for_checking = data_is_hash ? dig(data, rule.path) : DIG_MISS
110
138
 
111
139
  if DIG_MISS.equal?(for_checking)
112
140
  errors << [scope + rule.path, :missing] if rule.type == :required
@@ -36,7 +36,7 @@ module Karafka
36
36
  .first
37
37
  .split(":")
38
38
  .first
39
- .gsub(@tests_root_dir, "")
39
+ .sub(/\A#{::Regexp.escape(@tests_root_dir)}/, "")
40
40
  .gsub("_test.rb", "")
41
41
  .split("/")
42
42
  .delete_if(&:empty?)
@@ -39,7 +39,7 @@ module Karafka
39
39
  .first
40
40
  .split(":")
41
41
  .first
42
- .gsub(@specs_root_dir, "")
42
+ .sub(/\A#{::Regexp.escape(@specs_root_dir)}/, "")
43
43
  .gsub("_spec.rb", "")
44
44
  .split("/")
45
45
  .delete_if(&:empty?)
@@ -10,20 +10,21 @@ module Karafka
10
10
  # @return [::Karafka::Core::Instrumentation::CallbacksManager]
11
11
  def initialize
12
12
  @callbacks = {}
13
- @values_cache = nil
13
+ @values = [].freeze
14
+ @mutex = Mutex.new
14
15
  end
15
16
 
16
17
  # Invokes all the callbacks registered one after another
17
18
  #
18
19
  # @param args [Object] any args that should go to the callbacks
19
- # @note We do not use `#each_value` here on purpose. With it being used, we cannot dispatch
20
- # callbacks and add new at the same time. Since we don't know when and in what thread
21
- # things are going to be added to the manager, we need to extract values into an array and
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.
20
+ # @note Copy-on-write: dispatch iterates an immutable snapshot that `add`/`delete`
21
+ # rebuild and swap in under a mutex. Because `#call` never mutates shared state, it
22
+ # needs neither a lock nor a per-call `#values` allocation, and a callback registered
23
+ # or removed from another thread is never lost; it just takes effect on the next
24
+ # `#call`. A cache invalidated from within `#call` could not be updated atomically
25
+ # against this read, so a stale write-back would permanently drop callbacks.
25
26
  def call(*args)
26
- (@values_cache ||= @callbacks.values).each { |callback| callback.call(*args) }
27
+ @values.each { |callback| callback.call(*args) }
27
28
  end
28
29
 
29
30
  # Adds a callback to the manager
@@ -31,15 +32,19 @@ module Karafka
31
32
  # @param id [String] id of the callback (used when deleting it)
32
33
  # @param callable [#call] object that responds to a `#call` method
33
34
  def add(id, callable)
34
- @callbacks[id] = callable
35
- @values_cache = nil
35
+ @mutex.synchronize do
36
+ @callbacks[id] = callable
37
+ @values = @callbacks.values.freeze
38
+ end
36
39
  end
37
40
 
38
41
  # Removes the callback from the manager
39
42
  # @param id [String] id of the callback we want to remove
40
43
  def delete(id)
41
- @callbacks.delete(id)
42
- @values_cache = nil
44
+ @mutex.synchronize do
45
+ @callbacks.delete(id)
46
+ @values = @callbacks.values.freeze
47
+ end
43
48
  end
44
49
  end
45
50
  end
@@ -48,8 +48,17 @@ module Karafka
48
48
  # @param event_id [String] the key of the event to clear listeners for.
49
49
  def clear(event_id = nil)
50
50
  @mutex.synchronize do
51
- return @listeners.each_value(&:clear) unless event_id
52
- return @listeners[event_id].clear if @listeners.key?(event_id)
51
+ # Copy-on-write: replace the per-event arrays rather than mutating them in place, so a
52
+ # dispatch iterating a previously captured array is unaffected (see #notify_listeners).
53
+ unless event_id
54
+ @listeners.transform_values! { [] }
55
+ return
56
+ end
57
+
58
+ if @listeners.key?(event_id)
59
+ @listeners[event_id] = []
60
+ return
61
+ end
53
62
 
54
63
  raise(EventNotRegistered, "#{event_id} not registered!")
55
64
  end
@@ -76,14 +85,16 @@ module Karafka
76
85
 
77
86
  raise EventNotRegistered, event_id unless @listeners.key?(event_id)
78
87
 
79
- @listeners[event_id] << block
88
+ # Copy-on-write: append by replacing the array, never mutating the one a concurrent
89
+ # dispatch may be iterating (see #notify_listeners).
90
+ @listeners[event_id] += [block]
80
91
  else
81
92
  listener = event_id_or_listener
82
93
 
83
94
  @listeners.each_key do |reg_event_id|
84
95
  next unless listener.respond_to?(@events_methods_map[reg_event_id])
85
96
 
86
- @listeners[reg_event_id] << listener
97
+ @listeners[reg_event_id] += [listener]
87
98
  end
88
99
  end
89
100
  end
@@ -98,9 +109,9 @@ module Karafka
98
109
  # unsubscribe(my_listener)
99
110
  def unsubscribe(listener_or_block)
100
111
  @mutex.synchronize do
101
- @listeners.each_value do |event_listeners|
102
- event_listeners.delete(listener_or_block)
103
- end
112
+ # Copy-on-write: rebuild each array without the listener instead of deleting in place,
113
+ # so a dispatch iterating a previously captured array still sees the full set.
114
+ @listeners.transform_values! { |event_listeners| event_listeners - [listener_or_block] }
104
115
  end
105
116
  end
106
117
 
@@ -14,7 +14,16 @@ module Karafka
14
14
  # - KEY_fd - freeze duration - describes how long the delta remains unchanged (zero)
15
15
  # and can be useful for detecting values that "hang" for extended period of time
16
16
  # and do not have any change (delta always zero). This value is in ms for the
17
- # consistency with other time operators we use.
17
+ # consistency with other time operators we use. A newly introduced key (one
18
+ # that had no value in the previous emission) starts at a freeze duration of
19
+ # zero, since there is no prior value it could have been "frozen" against.
20
+ #
21
+ # The `_d` and `_fd` suffixes are reserved. For every numeric KEY the decorator writes
22
+ # `KEY_d` and `KEY_fd` into the same hash, so if the input already contains a real key
23
+ # literally named `KEY_d` or `KEY_fd` (for another numeric KEY present in that hash) it is
24
+ # overwritten by the computed delta/freeze duration. librdkafka statistics never use these
25
+ # suffixes, so this does not happen with real stats; it only matters if you feed custom data
26
+ # through the decorator.
18
27
  class StatisticsDecorator
19
28
  include Helpers::Time
20
29
 
@@ -28,11 +37,17 @@ module Karafka
28
37
  # duration suffixes. This is useful for skipping large subtrees of the librdkafka
29
38
  # statistics that are not consumed by the application (e.g. broker toppars, window
30
39
  # stats like int_latency, outbuf_latency, throttle, batchsize, batchcnt, req).
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.
40
+ # @param only_keys [Array<String>] when set, only these numeric keys are decorated with
41
+ # delta/freeze duration suffixes, and only at the levels of the known librdkafka
42
+ # statistics tree: the root, each broker, each topic, each partition and cgrp. The
43
+ # decorator navigates that known structure directly and decorates the listed keys it
44
+ # finds at each of those levels. It deliberately does NOT descend into nested
45
+ # sub-objects within a broker, topic, partition or cgrp (e.g. broker window stats like
46
+ # rtt/throttle/int_latency, or the toppars map) -- skipping that descent is exactly what
47
+ # makes this mode cheap on large clusters. Non-librdkafka hash children found at the
48
+ # root are still fully recursed for correctness. If you need a key nested inside one of
49
+ # those sub-objects decorated, use the default full-decoration mode. When empty
50
+ # (default), all numeric keys at every depth are decorated.
36
51
  def initialize(excluded_keys: [], only_keys: [])
37
52
  @previous = EMPTY_HASH
38
53
  # Operate on ms precision only
@@ -44,9 +59,12 @@ module Karafka
44
59
  @excluded_keys = unless excluded_keys.empty?
45
60
  excluded_keys.each_with_object({}) { |k, h| h[k] = true }.freeze
46
61
  end
47
- # Frozen array for direct-access decoration, nil when empty to use full decoration
62
+ # Frozen array for direct-access decoration, nil when empty to use full decoration.
63
+ # Exclusions are applied once here (excluded_keys wins over only_keys), so the hot
64
+ # decoration loop iterates an already-filtered list and never re-checks exclusions.
48
65
  @only_keys = unless only_keys.empty?
49
- only_keys.freeze
66
+ effective = @excluded_keys ? only_keys.reject { |k| @excluded_keys.key?(k) } : only_keys
67
+ effective.freeze
50
68
  end
51
69
  end
52
70
 
@@ -120,18 +138,17 @@ module Karafka
120
138
  if value.is_a?(Numeric)
121
139
  prev_value = filled_previous[key]
122
140
 
141
+ pair = cache[key] || (cache[key] = ["#{key}_fd".freeze, "#{key}_d".freeze].freeze)
142
+
123
143
  if prev_value.nil?
124
- result = 0
125
- elsif prev_value.is_a?(Numeric)
126
- result = value - prev_value
144
+ current[pair[0]] = 0
145
+ current[pair[1]] = 0
127
146
  else
128
- next
129
- end
130
-
131
- pair = cache[key] || (cache[key] = ["#{key}_fd".freeze, "#{key}_d".freeze].freeze)
147
+ result = value - prev_value
132
148
 
133
- current[pair[0]] = (result == 0) ? (filled_previous[pair[0]] || 0) + change_d : 0
134
- current[pair[1]] = result
149
+ current[pair[0]] = (result == 0) ? (filled_previous[pair[0]] || 0) + change_d : 0
150
+ current[pair[1]] = result
151
+ end
135
152
  elsif value.is_a?(Hash)
136
153
  diff_all(filled_previous[key], value, change_d)
137
154
  end
@@ -156,8 +173,13 @@ module Karafka
156
173
  # librdkafka statistics layout: root → brokers → broker, root → topics → topic →
157
174
  # partitions → partition, root → cgrp.
158
175
  #
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.
176
+ # Broker, topic, partition and cgrp nodes are decorated as leaves: their listed keys are
177
+ # decorated, but their nested sub-objects (e.g. broker window stats like rtt/throttle, or
178
+ # the toppars map) are NOT descended into. Not descending into those large sub-objects is
179
+ # the whole point of this path, so they are intentionally left untouched here.
180
+ #
181
+ # For non-librdkafka hash children at the root (e.g. custom or test data), falls back to
182
+ # generic recursion to maintain correctness with arbitrary nested structures.
161
183
  #
162
184
  # @param previous [Object] previous value from the given scope
163
185
  # @param current [Hash] current stats hash (root level)
@@ -209,8 +231,10 @@ module Karafka
209
231
  end
210
232
 
211
233
  # Consumer group (leaf-like)
212
- cgrp = current["cgrp"]
213
- decorate_keys(cgrp, filled_previous["cgrp"] || EMPTY_HASH, change_d) if cgrp.is_a?(Hash)
234
+ unless excluded&.key?("cgrp")
235
+ cgrp = current["cgrp"]
236
+ decorate_keys(cgrp, filled_previous["cgrp"] || EMPTY_HASH, change_d) if cgrp.is_a?(Hash)
237
+ end
214
238
 
215
239
  # Fallback: handle any non-standard hash children not in the known structure.
216
240
  # This ensures correctness for arbitrary nested data while the known paths above
@@ -275,18 +299,17 @@ module Karafka
275
299
 
276
300
  prev_value = filled_previous[key]
277
301
 
302
+ pair = cache[key] || (cache[key] = ["#{key}_fd".freeze, "#{key}_d".freeze].freeze)
303
+
278
304
  if prev_value.nil?
279
- result = 0
280
- elsif prev_value.is_a?(Numeric)
281
- result = value - prev_value
305
+ current[pair[0]] = 0
306
+ current[pair[1]] = 0
282
307
  else
283
- next
284
- end
285
-
286
- pair = cache[key] || (cache[key] = ["#{key}_fd".freeze, "#{key}_d".freeze].freeze)
308
+ result = value - prev_value
287
309
 
288
- current[pair[0]] = (result == 0) ? (filled_previous[pair[0]] || 0) + change_d : 0
289
- current[pair[1]] = result
310
+ current[pair[0]] = (result == 0) ? (filled_previous[pair[0]] || 0) + change_d : 0
311
+ current[pair[1]] = result
312
+ end
290
313
  end
291
314
  end
292
315
  end
@@ -27,9 +27,28 @@ module Karafka
27
27
  ) do |client_ptr, err_code, reason, _opaque|
28
28
  return nil unless ::Rdkafka::Config.error_callback
29
29
 
30
- name = ::Rdkafka::Bindings.rd_kafka_name(client_ptr)
30
+ # Guard against a null client pointer. librdkafka can invoke the error callback
31
+ # with a NULL `rd_kafka_t` (e.g. very early in client construction), and calling
32
+ # `rd_kafka_name` on it dereferences the pointer and segfaults the whole process.
33
+ # Mirrors the upstream `ErrorCallback`.
34
+ name = client_ptr.null? ? nil : ::Rdkafka::Bindings.rd_kafka_name(client_ptr)
35
+
36
+ # Resolve fatal errors to their underlying cause. `ERR__FATAL` is only a generic
37
+ # marker; the real error code and description must be fetched from librdkafka via
38
+ # `rd_kafka_fatal_error` (done by `RdkafkaError.build_fatal`). Without this the
39
+ # callback would report the generic fatal code instead of the actual error.
40
+ # Mirrors the upstream `ErrorCallback`.
41
+ error = if err_code == ::Rdkafka::Bindings::RD_KAFKA_RESP_ERR__FATAL
42
+ ::Rdkafka::RdkafkaError.build_fatal(
43
+ client_ptr,
44
+ fallback_error_code: err_code,
45
+ fallback_message: reason,
46
+ instance_name: name
47
+ )
48
+ else
49
+ ::Rdkafka::RdkafkaError.new(err_code, broker_message: reason)
50
+ end
31
51
 
32
- error = ::Rdkafka::RdkafkaError.new(err_code, broker_message: reason)
33
52
  error.set_backtrace(caller)
34
53
 
35
54
  ::Rdkafka::Config.error_callback.call(name, error)
@@ -10,6 +10,7 @@ module Karafka
10
10
  # Creates new tags accumulator
11
11
  def initialize
12
12
  @accu = {}
13
+ @values_cache = nil
13
14
  end
14
15
 
15
16
  # Adds a tag with a given name to tags
@@ -17,22 +18,25 @@ module Karafka
17
18
  # @param tag [#to_s] any object that can be converted into a string via `#to_s`
18
19
  def add(name, tag)
19
20
  @accu[name] = tag.to_s
21
+ @values_cache = nil
20
22
  end
21
23
 
22
24
  # Removes all the tags
23
25
  def clear
24
26
  @accu.clear
27
+ @values_cache = nil
25
28
  end
26
29
 
27
30
  # Removes a tag with a given name
28
31
  # @param name [Symbol] name of the tag
29
32
  def delete(name)
30
33
  @accu.delete(name)
34
+ @values_cache = nil
31
35
  end
32
36
 
33
37
  # @return [Array<String>] all unique tags registered
34
38
  def to_a
35
- @accu.values.tap(&:uniq!)
39
+ @values_cache ||= @accu.values.uniq
36
40
  end
37
41
 
38
42
  # @param _args [Object] anything that the standard `to_json` accepts
@@ -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.6.1"
7
+ VERSION = "2.6.2"
8
8
  end
9
9
  end
data/lib/karafka-core.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "logger"
4
4
  require "yaml"
5
+ require "pathname"
5
6
  require "rdkafka"
6
7
  require "karafka/core"
7
8
  require "karafka/core/version"
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.6.1
4
+ version: 2.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld