Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
qonfig (0.30.0)
qonfig (0.31.0)
base64 (>= 0.2)

GEM
Expand Down
2 changes: 1 addition & 1 deletion gemfiles/with_external_deps.gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ..
specs:
qonfig (0.30.0)
qonfig (0.31.0)
base64 (>= 0.2)

GEM
Expand Down
2 changes: 1 addition & 1 deletion gemfiles/without_external_deps.gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: ..
specs:
qonfig (0.30.0)
qonfig (0.31.0)
base64 (>= 0.2)

GEM
Expand Down
6 changes: 5 additions & 1 deletion lib/qonfig/commands/definition/add_nested_option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion lib/qonfig/commands/definition/re_define_option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
110 changes: 99 additions & 11 deletions lib/qonfig/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String, Symbol>]
# @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<String, Symbol>]
# @return [String, Array<String>]
#
# @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<Symbol, String>]
# @return [Object]
#
Expand Down
9 changes: 9 additions & 0 deletions lib/qonfig/settings/callbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Proc>]
Expand Down
2 changes: 1 addition & 1 deletion lib/qonfig/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ module Qonfig
#
# @api public
# @since 0.1.0
VERSION = '0.30.0'
VERSION = '0.31.0'
end
134 changes: 134 additions & 0 deletions spec/features/nested_access_caching_spec.rb
Original file line number Diff line number Diff line change
@@ -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