diff --git a/CHANGELOG.md b/CHANGELOG.md index a4966c4..6db46e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. ### Added - Support for `truffleruby`; +## [0.31.0] - 2026-06-24 +### Changed +- Performance: nested / dot-notated reads (`config['a.b.c.d.e.f']`, `config.dig(...)`) + are now memoized inside each config instance instead of re-splitting and re-digging + the key on every access. The cache is dropped on any mutation and the invalidation + cascades up through all parent config instances ("matryoshka" cache invalidation), + so nested configs (which are separate instances) never serve stale values. + ## [0.30.0] - 2024-12-14 ### Changed - Updated development dependencies; diff --git a/Gemfile.lock b/Gemfile.lock index 7645085..4515809 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - qonfig (0.30.0) + qonfig (0.31.0) base64 (>= 0.2) GEM diff --git a/gemfiles/with_external_deps.gemfile.lock b/gemfiles/with_external_deps.gemfile.lock index 20e7dab..a363440 100644 --- a/gemfiles/with_external_deps.gemfile.lock +++ b/gemfiles/with_external_deps.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - qonfig (0.30.0) + qonfig (0.31.0) base64 (>= 0.2) GEM diff --git a/gemfiles/without_external_deps.gemfile.lock b/gemfiles/without_external_deps.gemfile.lock index 8c23a05..f7b3e1e 100644 --- a/gemfiles/without_external_deps.gemfile.lock +++ b/gemfiles/without_external_deps.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - qonfig (0.30.0) + qonfig (0.31.0) base64 (>= 0.2) GEM diff --git a/lib/qonfig/commands/definition/add_nested_option.rb b/lib/qonfig/commands/definition/add_nested_option.rb index b1b55bd..ef4bc4a 100644 --- a/lib/qonfig/commands/definition/add_nested_option.rb +++ b/lib/qonfig/commands/definition/add_nested_option.rb @@ -41,10 +41,14 @@ def initialize(key, nested_definitions) # # @api private # @since 0.1.0 + # @version 0.31.0 def call(data_set, settings) nested_settings = nested_data_set_klass.new.settings - nested_settings.__mutation_callbacks__.add(settings.__mutation_callbacks__) + # NOTE: + # The nested instance is wired into the host's mutation-callback chain by + # Qonfig::Settings#__define_setting__ itself (see __store_setting_value__), + # so every nested-settings entry point shares one wiring path. settings.__define_setting__(key, nested_settings) end end diff --git a/lib/qonfig/commands/definition/re_define_option.rb b/lib/qonfig/commands/definition/re_define_option.rb index 7e26333..552ded0 100644 --- a/lib/qonfig/commands/definition/re_define_option.rb +++ b/lib/qonfig/commands/definition/re_define_option.rb @@ -48,10 +48,14 @@ def initialize(key, value, nested_definitions) # # @api private # @since 0.20.0 + # @version 0.31.0 def call(data_set, settings) if nested_data_set_klass nested_settings = nested_data_set_klass.new.settings - nested_settings.__mutation_callbacks__.add(settings.__mutation_callbacks__) + # NOTE: + # The nested instance is wired into the host's mutation-callback chain by + # Qonfig::Settings#__define_setting__ itself (see __store_setting_value__), + # so every nested-settings entry point shares one wiring path. settings.__define_setting__(key, nested_settings, with_redefinition: true) else settings.__define_setting__(key, value, with_redefinition: true) diff --git a/lib/qonfig/settings.rb b/lib/qonfig/settings.rb index 3a57762..2e94602 100644 --- a/lib/qonfig/settings.rb +++ b/lib/qonfig/settings.rb @@ -48,10 +48,24 @@ class Qonfig::Settings # NOTE: Layout/ClassStructure is disabled only for CORE_M # @api private # @since 0.1.0 + # @version 0.31.0 def initialize(__mutation_callbacks__) @__options__ = {} @__lock__ = Lock.new @__mutation_callbacks__ = __mutation_callbacks__ + @__deep_access_cache__ = {} + + # NOTE: + # Register a cache invalidator inside our own mutation callbacks. + # Nested settings add their parent's callbacks object to their own callbacks + # (see #__store_setting_value__), so a mutation of any nested config propagates + # upwards through the chain and drops the cached digging results on EACH config + # instance along the way ("matryoshka" cache invalidation). + # + # It is prepended so that it runs BEFORE the validation callback — full + # data-set validators read setting values back through #__dig__, so the + # cache must already be dropped by the time they run. + @__mutation_callbacks__.prepend(method(:__invalidate_deep_access_cache__)) end # @param block [Proc] @@ -93,7 +107,7 @@ def __deep_each_setting__(initial_setting_key = nil, yield_all: false, &block) # # @api private # @since 0.1.0 - # @version 0.20.0 + # @version 0.31.0 def __define_setting__(key, value, with_redefinition: false) # rubocop:disable Metrics/AbcSize __lock__.thread_safe_definition do key = __indifferently_accessable_option_key__(key) @@ -102,11 +116,11 @@ def __define_setting__(key, value, with_redefinition: false) # rubocop:disable M case when with_redefinition || !__options__.key?(key) - __options__[key] = value + __store_setting_value__(key, value) when __is_a_setting__(__options__[key]) && __is_a_setting__(value) __options__[key].__append_settings__(value) else - __options__[key] = value + __store_setting_value__(key, value) end __define_option_reader__(key) @@ -175,16 +189,21 @@ def __apply_values__(settings_map) # # @api private # @since 0.2.0 + # @version 0.31.0 def __dig__(*keys) __lock__.thread_safe_access do - begin - __deep_access__(*keys) - rescue Qonfig::UnknownSettingError - if keys.size == 1 - __deep_access__(*__parse_dot_notated_key__(keys.first)) - else - raise - end + # NOTE: + # Resolving a dot-notated/nested key (config['a.b.c.d']) is expensive: + # it splits and re-joins string fragments and digs through every nested + # config instance on each access. We memoize the resolved value per key + # set; the cache is dropped on any mutation (see __invalidate...). + cache = @__deep_access_cache__ + cache_key = __deep_access_cache_key__(keys) + + if cache.key?(cache_key) + cache[cache_key] + else + __resolve_deep_access__(*keys).tap { |value| cache[cache_key] = value } end end end @@ -575,6 +594,75 @@ def __set_value__(key, value) __invoke_mutation_callbacks__ end + # @param key [String] + # @param value [Object] + # @return [void] + # + # @api private + # @since 0.31.0 + def __store_setting_value__(key, value) + __options__[key] = value + + # NOTE: + # A nested settings instance can arrive here "foreign" — built under another + # data set and merged into us by reference (compose / load_from_* / expose_* / + # option re-definition). Its mutation callbacks would then still point only at + # the discarded foreign parent, so a mutation of the nested config would never + # invalidate OUR deep-access cache (=> stale reads). Wire the nested instance + # into our callback chain on assignment so invalidation always cascades upwards + # ("matryoshka"), regardless of how the nested settings instance got here. + value.__mutation_callbacks__.add(__mutation_callbacks__) if __is_a_setting__(value) + end + + # @param keys [Array] + # @return [Object] + # + # @raise [Qonfig::UnknownSettingError] + # + # @api private + # @since 0.31.0 + def __resolve_deep_access__(*keys) + __deep_access__(*keys) + rescue Qonfig::UnknownSettingError + if keys.size == 1 + __deep_access__(*__parse_dot_notated_key__(keys.first)) + else + raise + end + end + + # @param keys [Array] + # @return [String, Array] + # + # @api private + # @since 0.31.0 + def __deep_access_cache_key__(keys) + # NOTE: + # Stringify keys so that symbol/string variants share a single cache slot + # (indifferent access), while preserving key boundaries — ['a.b', 'c'] and + # ['a', 'b', 'c'] resolve differently and must not collide. The single-key + # case (the dominant config['a.b.c'] form) avoids an extra array allocation. + (keys.size == 1) ? keys.first.to_s : keys.map(&:to_s) + end + + # @return [void] + # + # @api private + # @since 0.31.0 + def __invalidate_deep_access_cache__ + # NOTE: + # Reassign (instead of #clear) so concurrent readers never observe a + # half-cleared hash — invalidation can propagate upwards from a nested + # mutation WITHOUT holding our access lock. + # + # The reassignment must be UNCONDITIONAL. A `unless empty?` guard would skip + # the swap during the window where a concurrent reader has already resolved a + # now-stale value but has not yet written it into the (still-empty) cache — + # the stale write would then survive and be served forever. Rebinding the ivar + # to a fresh hash makes such a late write land in an orphaned hash instead. + @__deep_access_cache__ = {} + end + # @param keys [Array] # @return [Object] # diff --git a/lib/qonfig/settings/callbacks.rb b/lib/qonfig/settings/callbacks.rb index 7ea30f0..4a20cb8 100644 --- a/lib/qonfig/settings/callbacks.rb +++ b/lib/qonfig/settings/callbacks.rb @@ -35,6 +35,15 @@ def add(callback) thread_safe { callbacks << callback } end + # @param callback [Proc, Qonfig::Settings::Callbacks, #call] + # @return [void] + # + # @api private + # @since 0.31.0 + def prepend(callback) + thread_safe { callbacks.unshift(callback) } + end + private # @return [Array] diff --git a/lib/qonfig/version.rb b/lib/qonfig/version.rb index c1068bc..91f0af1 100644 --- a/lib/qonfig/version.rb +++ b/lib/qonfig/version.rb @@ -5,5 +5,5 @@ module Qonfig # # @api public # @since 0.1.0 - VERSION = '0.30.0' + VERSION = '0.31.0' end diff --git a/spec/features/nested_access_caching_spec.rb b/spec/features/nested_access_caching_spec.rb new file mode 100644 index 0000000..e4630f3 --- /dev/null +++ b/spec/features/nested_access_caching_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +describe 'Nested access caching (with matryoshka cache invalidation)' do + let(:config) do + Qonfig::DataSet.build do + setting :a do + setting :b do + setting :c do + setting :d, 1 + end + end + end + + setting :flat, 'value' + end + end + + specify 'repeated dot-notated / nested reads return the same (memoized) result' do + expect(config['a.b.c.d']).to eq(1) + expect(config['a.b.c.d']).to eq(1) + + nested = config['a.b.c'] + expect(nested).to be_a(Qonfig::Settings) + # the very same nested settings instance is returned from the cache + expect(config['a.b.c']).to equal(nested) + expect(config.dig(:a, :b, :c)).to equal(nested) + end + + specify 'symbol and string key variants share the cache (indifferent access)' do + expect(config[:a][:b][:c][:d]).to eq(1) + expect(config['a.b.c.d']).to eq(1) + expect(config.dig('a', :b, 'c', :d)).to eq(1) + end + + specify 'assigning a value invalidates the cache' do + expect(config['a.b.c.d']).to eq(1) + + config['a.b.c.d'] = 2 + expect(config['a.b.c.d']).to eq(2) + end + + specify 'mutating a nested config instance invalidates every parent cache (matryoshka)' do + # warm up caches on every level + expect(config['a.b.c.d']).to eq(1) + expect(config['a.b.c']['d']).to eq(1) + + # mutate through a directly-grabbed nested instance + nested = config['a.b.c'] + nested['d'] = 99 + + # the mutation must be visible from the root through the (now stale) cache + expect(config['a.b.c.d']).to eq(99) + expect(config['a.b']['c.d']).to eq(99) + expect(nested['d']).to eq(99) + end + + specify 'a flat value mutation invalidates the cache' do + expect(config['flat']).to eq('value') + config['flat'] = 'changed' + expect(config['flat']).to eq('changed') + end + + specify '#clear! invalidates the cache' do + expect(config['a.b.c.d']).to eq(1) + config.clear! + expect(config['a.b.c.d']).to be_nil + end + + specify 'full data-set validators observe fresh values after mutation (no stale cache)' do + validated_config = Class.new(Qonfig::DataSet) do + setting :nested do + setting :flag, :yes + end + + validate { settings.nested.flag.is_a?(Symbol) } + end + + instance = validated_config.new + # warm the cache through the validator-read path + expect(instance.settings.nested.flag).to eq(:yes) + + expect { instance.settings.nested.flag = 123 }.to raise_error(Qonfig::ValidationError) + end + + specify 'composed nested settings invalidate the host cache on mutation' do + shared = Class.new(Qonfig::DataSet) do + setting :s do + setting :v, 1 + end + end + + host = Qonfig::DataSet.build { compose shared } + + expect(host['s.v']).to eq(1) # warm up the cache through the host + host['s.v'] = 88 + # the nested instance was merged in "by reference"; mutating it must still + # cascade invalidation up to the host (otherwise the host serves a stale 1) + expect(host['s.v']).to eq(88) + end + + specify 're-defined nested settings invalidate the cache on mutation' do + redefined_config = Class.new(Qonfig::DataSet) do + setting :s do + setting :v, 1 + end + + re_setting :s do + setting :v, 10 + end + end + + instance = redefined_config.new + + expect(instance['s.v']).to eq(10) # warm up the cache + instance['s.v'] = 77 + expect(instance['s.v']).to eq(77) + end + + specify 'cache invalidation always swaps in a fresh hash, even when already empty' do + # Guards the lock-free invalidation contract: invalidation may propagate up + # from a nested mutation without holding our access lock, so the swap must be + # unconditional. A `unless empty?` guard would let a concurrent reader that has + # resolved a stale value (but not yet written it) persist that value forever. + settings = config.settings + empty_cache = settings.instance_variable_get(:@__deep_access_cache__) + expect(empty_cache).to be_empty + + settings.send(:__invalidate_deep_access_cache__) + + swapped_cache = settings.instance_variable_get(:@__deep_access_cache__) + expect(swapped_cache).to be_empty + expect(swapped_cache).not_to equal(empty_cache) + end +end