karafka-core 2.6.0 → 2.6.1

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: f2e60176350192fdf31ec49aa111aa1a2cd51e4f7d0785b5d31070422dd07fbd
4
- data.tar.gz: 07e237b85c91e7d0886a8c8a91e7d55a8f54aaa59cdd18a1281f1e0cff38357a
3
+ metadata.gz: 2d9f6ba569f0c3a911cbe3ce80c545f3069b5aa2560782cf88bd11c97baa0432
4
+ data.tar.gz: 54f274da23e99841809f884ea871ce9a55705d76d3c4e5c40b0ef2ffbe0ae75a
5
5
  SHA512:
6
- metadata.gz: 52a0a727b505b2f69720aac6be4bc7af9803d347ce9e01f7335a3d58bec4b2e4e6bc4f302c860f6564bcd53a81dbe36e2138e90e81d24bfe11d0c5caee38fa32
7
- data.tar.gz: e2b40e1316e748b42301f58a472239458fb4ce34a10b5efe43b436b6aeda70df872df10f2a14fb0d7f851c55fedd045b46bb2e9bb5e896377e2bb2d505e9f5e2
6
+ metadata.gz: 3a6c6ba64216479cebb2c4f574dff194ae6665189f382b0a60193c5b4ec3ab4d55f4ff97d7fb3fc90800f105a5284da27493b5694adff6e1a6c0738fa5795f65
7
+ data.tar.gz: 8ea0eddcd9ae95603041f8a74a1a26311cd9aa13744759dd6e7a699eb6ce682fd89596ef85e77ee2fd280d4aa3eca14d8f2ddd7a75dc322fe6e7bb33903ac7b6
@@ -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@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
40
+ uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
41
41
  with:
42
42
  ruby-version: ${{matrix.ruby}}
43
43
  bundler: 'latest'
@@ -69,7 +69,7 @@ jobs:
69
69
  with:
70
70
  fetch-depth: 0
71
71
  - name: Set up Ruby
72
- uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
72
+ uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
73
73
  with:
74
74
  ruby-version: '4.0.5'
75
75
  bundler-cache: true
@@ -86,7 +86,7 @@ jobs:
86
86
  with:
87
87
  fetch-depth: 0
88
88
  - name: Set up Ruby
89
- uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
89
+ uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
90
90
  with:
91
91
  ruby-version: '4.0.5'
92
92
  bundler-cache: true
@@ -24,7 +24,7 @@ jobs:
24
24
  fetch-depth: 0
25
25
 
26
26
  - name: Set up Ruby
27
- uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
27
+ uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
28
28
  with:
29
29
  bundler-cache: false
30
30
 
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Karafka Core Changelog
2
2
 
3
+ ## 2.6.1 (2026-06-15)
4
+ - [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
+ - [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.
6
+ - [Change] Reject reserved setting names with an `ArgumentError` in `Configurable::Node#setting` and `#register`: internal state names (`node_name`, `children`, `nestings`, `compiled`, `configs_refs`, `local_defs`) and the node public API names (`setting`, `configure`, `to_h`, `deep_dup`, `register`, `compile`). Previously such names silently shadowed the node own accessors, breaking `deep_dup` or `to_h`, and assignments like `config.children = value` corrupted the node internal state.
7
+ - [Enhancement] Skip the event name mapping hash lookup in `Monitor#instrument` when no namespace is used and the event id is already a `String`, which is the case for all events in the Karafka ecosystem (~1.2x faster dispatch on the common no-subscribers path). Symbol event ids and namespaced monitors keep going through the mapping.
8
+ - [Enhancement] Mirror config values into instance variables and use `attr_reader` based readers in `Configurable::Node`, yielding ~1.4x faster flat and ~1.6x faster nested settings reads on hot paths. `@configs_refs` remains the canonical store; non-identifier setting names (e.g. registered names with dashes) keep the previous hash-based accessors.
9
+ - [Enhancement] Instantiate each `Configurable::Node` through a per-layout anonymous subclass so the ivar-backed settings do not grow object shape variations on the shared `Node` class (which would degrade ivar access and trigger Ruby performance warnings). `deep_dup` reuses the template's subclass, so duplicated configs share object shapes.
10
+ - [Fix] Symbolize setting names at definition time (`setting`, same as `register`) and on config store writes so `String` setting names work end to end (accessors, `#to_h`, recompilation state) and cannot corrupt node internal state when matching reserved internal names (previously string-named settings were quietly broken as accessors and the store disagreed on the key type).
11
+ - [Change] Config nodes are now instances of anonymous `Node` subclasses: `is_a?(Karafka::Core::Configurable::Node)` still holds, but `instance_of?(Node)` is now `false` and `node.class.name` is `nil`.
12
+ - [Change] Assigning a setting on a frozen config node now raises `FrozenError` (previously the write silently mutated internal storage despite the freeze).
13
+
3
14
  ## 2.6.0 (2026-06-10)
4
15
  - [Enhancement] Add `Node#register` to allow runtime key-value registration on compiled nodes without going through the static `setting` DSL. Useful for dynamic registries (e.g. named clusters) where setting names are not known at class-load time.
5
16
  - [Enhancement] Replace version-gated `Warning[:performance]` with a `Warning.categories`-based loop that enables all opt-in Ruby warning categories automatically, picking up new categories (e.g. `strict_unused_block` in Ruby 3.4+) without future patches.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- karafka-core (2.6.0)
4
+ karafka-core (2.6.1)
5
5
  karafka-rdkafka (>= 0.20.0)
6
6
  logger (>= 1.6.0)
7
7
 
@@ -15,6 +15,49 @@ module Karafka
15
15
  # We need to be able to redefine children for deep copy
16
16
  attr_accessor :children
17
17
 
18
+ # Names that cannot be used as setting names because they would collide with the node
19
+ # internal state or the node public API: their accessors would shadow the node own
20
+ # readers (breaking for example `#deep_dup` or `#to_h`) and writers like `children=`
21
+ # would overwrite internal ivars. `#setting` and `#register` reject them upfront and
22
+ # `#ivar_backed?` keeps guarding the ivar mirror as defense in depth.
23
+ # Private method names are deliberately not reserved: that would make internal
24
+ # implementation details part of the public contract
25
+ RESERVED_NAMES = %i[
26
+ node_name
27
+ children
28
+ nestings
29
+ compiled
30
+ configs_refs
31
+ local_defs
32
+ setting
33
+ configure
34
+ to_h
35
+ deep_dup
36
+ register
37
+ compile
38
+ ].to_h { |name| [name, true] }.freeze
39
+
40
+ # Setting names that match this format can be backed by instance variables and use the
41
+ # fast `attr_reader` based readers. Others (e.g. registered names with dashes) fall back
42
+ # to the hash-based accessors
43
+ IVAR_NAMEABLE_FORMAT = /\A[A-Za-z_][A-Za-z0-9_]*\z/
44
+
45
+ private_constant :RESERVED_NAMES, :IVAR_NAMEABLE_FORMAT
46
+
47
+ class << self
48
+ # Builds each node through its own anonymous subclass. Since setting values are
49
+ # mirrored into instance variables for fast access and each node layout carries a
50
+ # different set of them, instantiating nodes directly from this class would grow its
51
+ # object shape variations past the Ruby limit, degrading ivar access for all nodes.
52
+ # A subclass per layout keeps shape variations per class minimal (late `setting`
53
+ # calls after inheritance or runtime `register` calls may add a few more, staying
54
+ # well under the limit). `#deep_dup` reuses the subclass of its template, so
55
+ # duplicated configs share shapes as well.
56
+ def new(...)
57
+ equal?(Node) ? Class.new(self).new(...) : super
58
+ end
59
+ end
60
+
18
61
  # @param node_name [Symbol] node name
19
62
  # @param nestings [Proc] block for nested settings
20
63
  # @param evaluate [Boolean] when false, skip evaluating the nestings block. Used by
@@ -31,12 +74,20 @@ module Karafka
31
74
 
32
75
  # Allows for a single leaf or nested node definition
33
76
  #
34
- # @param node_name [Symbol] setting or nested node name
77
+ # @param node_name [Symbol, String] setting or nested node name
35
78
  # @param default [Object] default value
36
79
  # @param constructor [#call, nil] callable or nil
37
80
  # @param lazy [Boolean] is this a lazy leaf
38
81
  # @param block [Proc] block for nested settings
82
+ # @raise [ArgumentError] when the name is reserved for the node internal state
39
83
  def setting(node_name, default: nil, constructor: nil, lazy: false, &block)
84
+ # Symbolize at definition time (same as `#register`) so the config store, accessors,
85
+ # `#to_h` and the compile state checks all agree on the key type also when a String
86
+ # name is provided
87
+ node_name = node_name.to_sym
88
+
89
+ prevent_reserved_names!(node_name)
90
+
40
91
  @children << if block
41
92
  Node.new(node_name, block)
42
93
  else
@@ -85,7 +136,8 @@ module Karafka
85
136
  # and non-side-effect usage on an instance/inherited.
86
137
  # @return [Node] duplicated node
87
138
  def deep_dup
88
- dupped = Node.new(node_name, nestings, evaluate: false)
139
+ # Same-layout nodes reuse the class of their template so they share object shapes
140
+ dupped = self.class.new(node_name, nestings, evaluate: false)
89
141
 
90
142
  children.each do |value|
91
143
  dupped.children << if value.is_a?(Leaf)
@@ -116,16 +168,19 @@ module Karafka
116
168
  # @param name [Symbol, String] setting name
117
169
  # @param value [Object] the setting value assigned immediately; also used as the default
118
170
  # when the node is deep-duped and recompiled on a new instance
119
- # @raise [ArgumentError] when the name is already taken
171
+ # @raise [ArgumentError] when the name is already taken or reserved for the node
172
+ # internal state
120
173
  def register(name, value)
121
174
  name = name.to_sym
122
175
 
176
+ prevent_reserved_names!(name)
177
+
123
178
  raise ArgumentError, "#{name} is already registered" if @configs_refs.key?(name)
124
179
 
125
180
  leaf = Leaf.new(name, value, nil, true, false)
126
181
  @children << leaf
127
182
  build_accessors(leaf)
128
- @configs_refs[name] = value
183
+ config_write(name, value)
129
184
  end
130
185
 
131
186
  # Converts the settings definitions into end children
@@ -161,7 +216,7 @@ module Karafka
161
216
  if lazy_leaf && !initialized
162
217
  build_dynamic_accessor(value)
163
218
  else
164
- @configs_refs[value.node_name] = initialized
219
+ config_write(value.node_name, initialized)
165
220
  end
166
221
  end
167
222
 
@@ -207,6 +262,12 @@ module Karafka
207
262
 
208
263
  # Builds regular accessors for value fetching
209
264
  #
265
+ # Settings with names that can form valid instance variables get `attr_reader` based
266
+ # readers backed by an ivar mirror of the config value. This is significantly faster
267
+ # than a method with a hash lookup, which matters since settings are read on hot paths
268
+ # across the whole ecosystem. `@configs_refs` remains the canonical store used by
269
+ # `#to_h`, `#compile` and `#register`, with `#config_write` keeping the mirror in sync.
270
+ #
210
271
  # @param value [Leaf]
211
272
  def build_accessors(value)
212
273
  reader_name = value.node_name.to_sym
@@ -219,17 +280,64 @@ module Karafka
219
280
  if reader_respond ? !@local_defs.key?(reader_name) : true
220
281
  @local_defs[reader_name] = true
221
282
 
222
- define_singleton_method(reader_name) do
223
- @configs_refs[reader_name]
283
+ if ivar_backed?(reader_name)
284
+ singleton_class.attr_reader(reader_name)
285
+ else
286
+ define_singleton_method(reader_name) do
287
+ @configs_refs[reader_name]
288
+ end
224
289
  end
225
290
  end
226
291
 
227
- return if respond_to?(:"#{value.node_name}=")
292
+ return if respond_to?(:"#{reader_name}=")
228
293
 
229
- define_singleton_method(:"#{value.node_name}=") do |new_value|
230
- @configs_refs[value.node_name] = new_value
294
+ if ivar_backed?(reader_name)
295
+ ivar_name = :"@#{reader_name}"
296
+
297
+ define_singleton_method(:"#{reader_name}=") do |new_value|
298
+ instance_variable_set(ivar_name, @configs_refs[reader_name] = new_value)
299
+ end
300
+ else
301
+ define_singleton_method(:"#{reader_name}=") do |new_value|
302
+ @configs_refs[reader_name] = new_value
303
+ end
231
304
  end
232
305
  end
306
+
307
+ # Writes a config value to the canonical store and mirrors it into the backing instance
308
+ # variable when the setting uses the fast ivar-backed reader
309
+ #
310
+ # @param name [Symbol, String] setting name
311
+ # @param value [Object] config value assigned to the setting
312
+ def config_write(name, value)
313
+ # Accessors operate on symbolized names, so the store has to be keyed consistently.
314
+ # This also guarantees that a String name matching a reserved internal name is
315
+ # recognized by the `ivar_backed?` guard and cannot corrupt the node internal state
316
+ name = name.to_sym
317
+
318
+ @configs_refs[name] = value
319
+ instance_variable_set(:"@#{name}", value) if ivar_backed?(name)
320
+ end
321
+
322
+ # @param name [Symbol] setting name
323
+ # @return [Boolean] true if this setting can be backed by an instance variable and use
324
+ # the fast `attr_reader` based reader
325
+ def ivar_backed?(name)
326
+ !RESERVED_NAMES.key?(name) && IVAR_NAMEABLE_FORMAT.match?(name)
327
+ end
328
+
329
+ # Rejects setting names that would collide with the node internal state. Without this,
330
+ # such names would shadow the node own accessors, breaking `#deep_dup` and silently
331
+ # corrupting internals on assignment (e.g. `config.children = value` hitting the node
332
+ # own `attr_writer`)
333
+ #
334
+ # @param name [Symbol] already symbolized setting name
335
+ # @raise [ArgumentError] when the name is reserved
336
+ def prevent_reserved_names!(name)
337
+ return unless RESERVED_NAMES.key?(name)
338
+
339
+ raise ArgumentError, "#{name} is a reserved name and cannot be used as a setting name"
340
+ end
233
341
  end
234
342
  end
235
343
  end
@@ -15,7 +15,7 @@ module Karafka
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
18
+ # `#call` / `#validate!` invocation. Safe because scope is never mutated - it is only
19
19
  # used in `scope + rule.path` which creates a new Array.
20
20
  EMPTY_ARRAY = [].freeze
21
21
 
@@ -81,6 +81,12 @@ module Karafka
81
81
 
82
82
  # Runs the validation
83
83
  #
84
+ # The per-rule handling is inlined instead of dispatching to per-type methods because
85
+ # this runs per rule per validation, including the per-message validations in
86
+ # WaterDrop. Required and optional rules share the whole flow except the missing-key
87
+ # handling. `DIG_MISS` is compared via `#equal?` so we never dispatch `#==` to the
88
+ # validated (user-provided) values.
89
+ #
84
90
  # @param data [Hash] hash with data we want to validate
85
91
  # @param scope [Array<String>] scope of this contract (if any) or empty array if no parent
86
92
  # scope is needed if contract starts from root
@@ -89,13 +95,28 @@ module Karafka
89
95
  errors = []
90
96
 
91
97
  self.class.rules.each do |rule|
92
- case rule.type
93
- when :required
94
- validate_required(data, rule, errors, scope)
95
- when :optional
96
- validate_optional(data, rule, errors, scope)
97
- when :virtual
98
- validate_virtual(data, rule, errors, scope)
98
+ if rule.type == :virtual
99
+ result = rule.validator.call(data, errors, self)
100
+
101
+ next if result == true
102
+
103
+ result&.each do |sub_result|
104
+ sub_result[0] = scope + sub_result[0]
105
+ end
106
+
107
+ errors.push(*result)
108
+ else
109
+ for_checking = dig(data, rule.path)
110
+
111
+ if DIG_MISS.equal?(for_checking)
112
+ errors << [scope + rule.path, :missing] if rule.type == :required
113
+ else
114
+ result = rule.validator.call(for_checking, data, errors, self)
115
+
116
+ next if result == true
117
+
118
+ errors << [scope + rule.path, result || :format]
119
+ end
99
120
  end
100
121
  end
101
122
 
@@ -121,98 +142,39 @@ module Karafka
121
142
 
122
143
  private
123
144
 
124
- # Runs validation for rules on fields that are required and adds errors (if any) to the
125
- # errors array
126
- #
127
- # @param data [Hash] input hash
128
- # @param rule [Rule] validation rule
129
- # @param errors [Array] array with errors from previous rules (if any)
130
- # @param scope [Array<String>]
131
- def validate_required(data, rule, errors, scope)
132
- for_checking = dig(data, rule.path)
133
-
134
- # We need to compare `DIG_MISS` against stuff because of the ownership of the `#==` method
135
- if for_checking == DIG_MISS
136
- errors << [scope + rule.path, :missing]
137
- else
138
- result = rule.validator.call(for_checking, data, errors, self)
139
-
140
- return if result == true
141
-
142
- errors << [scope + rule.path, result || :format]
143
- end
144
- end
145
-
146
- # Runs validation for rules on fields that are optional and adds errors (if any) to the
147
- # errors array
148
- #
149
- # @param data [Hash] input hash
150
- # @param rule [Rule] validation rule
151
- # @param errors [Array] array with errors from previous rules (if any)
152
- # @param scope [Array<String>]
153
- def validate_optional(data, rule, errors, scope)
154
- for_checking = dig(data, rule.path)
155
-
156
- return if for_checking == DIG_MISS
157
-
158
- result = rule.validator.call(for_checking, data, errors, self)
159
-
160
- return if result == true
161
-
162
- errors << [scope + rule.path, result || :format]
163
- end
164
-
165
- # Runs validation for rules on virtual fields (aggregates, etc) and adds errors (if any) to
166
- # the errors array
167
- #
168
- # @param data [Hash] input hash
169
- # @param rule [Rule] validation rule
170
- # @param errors [Array] array with errors from previous rules (if any)
171
- # @param scope [Array<String>]
172
- def validate_virtual(data, rule, errors, scope)
173
- result = rule.validator.call(data, errors, self)
174
-
175
- return if result == true
176
-
177
- result&.each do |sub_result|
178
- sub_result[0] = scope + sub_result[0]
179
- end
180
-
181
- errors.push(*result)
182
- end
183
-
184
145
  # Tries to dig for a given key in a hash and returns it with indication whether or not it
185
146
  # was possible to find it (dig returns nil and we don't know if it wasn't the digged key
186
147
  # value)
187
148
  #
149
+ # Uses `Hash#fetch` with the `DIG_MISS` sentinel as the default, which resolves presence
150
+ # and value in a single hash lookup instead of a `key?` check followed by `[]`. This
151
+ # runs per rule per validation, including the per-message validations in WaterDrop,
152
+ # hence the lookup count matters. `fetch` with a default ignores `default_proc`, same
153
+ # as the previous `key?` based logic.
154
+ #
188
155
  # @param data [Hash]
189
156
  # @param keys [Array<Symbol>]
190
157
  # @return [DIG_MISS, Object] found element or DIGG_MISS indicating that not found
191
158
  def dig(data, keys)
192
159
  case keys.length
193
160
  when 1
194
- key = keys[0]
195
-
196
- return DIG_MISS unless data.key?(key)
197
-
198
- data[key]
161
+ data.fetch(keys[0], DIG_MISS)
199
162
  when 2
200
- key1 = keys[0]
201
-
202
- return DIG_MISS unless data.key?(key1)
163
+ mid = data.fetch(keys[0], DIG_MISS)
203
164
 
204
- mid = data[key1]
165
+ return DIG_MISS if DIG_MISS.equal?(mid)
166
+ return DIG_MISS unless mid.is_a?(Hash)
205
167
 
206
- return DIG_MISS unless mid.is_a?(Hash) && mid.key?(keys[1])
207
-
208
- mid[keys[1]]
168
+ mid.fetch(keys[1], DIG_MISS)
209
169
  else
210
170
  current = data
211
171
 
212
172
  keys.each do |nesting|
213
- return DIG_MISS unless current.key?(nesting)
173
+ return DIG_MISS unless current.is_a?(Hash)
174
+
175
+ current = current.fetch(nesting, DIG_MISS)
214
176
 
215
- current = current[nesting]
177
+ return DIG_MISS if DIG_MISS.equal?(current)
216
178
  end
217
179
 
218
180
  current
@@ -28,7 +28,15 @@ module Karafka
28
28
  # @param event_id [String, Symbol] event id
29
29
  # @param payload [Hash]
30
30
  def instrument(event_id, payload = EMPTY_HASH, &)
31
- full_event_name = @mapped_events[event_id] ||= [event_id, @namespace].compact.join(".")
31
+ # With no namespace, string event ids already are the full event names. This is the
32
+ # case for all the events in the Karafka ecosystem, so we can skip the mapping hash
33
+ # lookup on this hot path. Symbols still go through the mapping to be converted into
34
+ # strings without allocating on each call.
35
+ full_event_name = if @namespace.nil? && event_id.is_a?(String)
36
+ event_id
37
+ else
38
+ @mapped_events[event_id] ||= [event_id, @namespace].compact.join(".")
39
+ end
32
40
 
33
41
  @notifications_bus.instrument(full_event_name, payload, &)
34
42
  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.6.0"
7
+ VERSION = "2.6.1"
8
8
  end
9
9
  end
data/renovate.json CHANGED
@@ -15,7 +15,7 @@
15
15
  {
16
16
  "minimumReleaseAge": "7 days",
17
17
  "matchDepNames": [
18
- "/*/"
18
+ "*"
19
19
  ]
20
20
  },
21
21
  {
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.0
4
+ version: 2.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Mensfeld