Skip to content
Merged
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
1 change: 1 addition & 0 deletions ruby/optify/.vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"aset",
"capa",
"chruby",
"getset",
"Magnus",
"qnil",
"rubocop",
Expand Down
41 changes: 41 additions & 0 deletions ruby/optify/lib/optify_ruby/cache_init_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# typed: strict
# frozen_string_literal: true

module Optify
# The mode for the cache.
module CacheMode
# Thread-safe LRU cache.
THREAD_SAFE = :thread_safe #: Symbol
# Non-thread-safe LRU cache.
# Should be faster than `THREAD_SAFE` for single-threaded applications.
NOT_THREAD_SAFE = :not_thread_safe #: Symbol
end

# Options for initializing the cache.
class CacheInitOptions
#: Integer?
attr_reader :max_size

# A value from `CacheMode`.
#
#: Symbol
attr_reader :mode

# Initializes the cache options.
# Defaults to a non-thread-safe unlimited size cache for backwards compatibility
# with how this library was originally configured with an unbounded hash as the case.
# @param mode A value from `CacheMode`.
#
#: (
#| ?max_size: Integer?,
#| ?mode: Symbol,
#| ) -> void
def initialize(
max_size: nil,
mode: CacheMode::NOT_THREAD_SAFE
)
@max_size = max_size
@mode = mode
end
end
end
8 changes: 5 additions & 3 deletions ruby/optify/lib/optify_ruby/implementation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require 'sorbet-runtime'

require_relative './base_config'
require_relative './cache_init_options'
require_relative './options_metadata'
require_relative './provider_module'

Expand Down Expand Up @@ -31,10 +32,11 @@ def get_options(key, feature_names, config_class, cache_options = nil, preferenc
end

# (Optional) Eagerly initializes the cache.
# @param cache_init_options Options for initializing the cache.
# @return [OptionsProvider] `self`.
#: -> OptionsProvider
def init
_init
#: (?CacheInitOptions?) -> OptionsProvider
def init(cache_init_options = nil)
_init(cache_init_options)
self
end
end
Expand Down
81 changes: 58 additions & 23 deletions ruby/optify/lib/optify_ruby/provider_module.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,47 @@
# frozen_string_literal: true

require 'json'
require 'lru_redux'
require 'sorbet-runtime'

module Optify
# @!visibility private
module ProviderModule
#: [T] (LruRedux::Cache | Hash[untyped, untyped], Array[untyped]) { -> T } -> T
def self._cache_getset(cache, cache_key, &block)
if cache.is_a? LruRedux::Cache
cache.getset(cache_key, &block)
else
# Plain Hash - use fetch with block and store result
cache.fetch(cache_key) do
result = block.call
cache[cache_key] = result
end
end
end

#: (CacheInitOptions?) -> ( Hash[Array[untyped], untyped] | LruRedux::Cache)
def self._create_cache(cache_init_options)
# Be backwards compatible with the original implementation of this library.
return {} if cache_init_options.nil?

max_size = cache_init_options.max_size
mode = cache_init_options.mode
if max_size.nil?
Kernel.raise ArgumentError, 'Thread-safe cache is not supported when max_size is nil' if mode == CacheMode::THREAD_SAFE
{}
else
case mode
when CacheMode::THREAD_SAFE
LruRedux::ThreadSafeCache.new(max_size)
when CacheMode::NOT_THREAD_SAFE
LruRedux::Cache.new(max_size)
else
Kernel.raise ArgumentError, "Invalid cache mode: #{mode}"
end
end
end

#: (Array[String] feature_names) -> Array[String]
def get_canonical_feature_names(feature_names)
# Try to optimize a typical case where there are just a few features.
Expand Down Expand Up @@ -55,7 +91,7 @@ def _features_with_metadata
# @return The options.
#: [Config] (String, Array[String], Class[Config], ?CacheOptions?, ?Optify::GetOptionsPreferences?) -> Config
def _get_options(key, feature_names, config_class, cache_options = nil, preferences = nil)
return get_options_with_cache(key, feature_names, config_class, cache_options, preferences) if cache_options
return _get_options_with_cache(key, feature_names, config_class, cache_options, preferences) if cache_options

unless config_class.respond_to?(:from_hash)
Kernel.raise NotImplementedError,
Expand All @@ -73,14 +109,8 @@ def _get_options(key, feature_names, config_class, cache_options = nil, preferen
.from_hash(hash)
end

#: -> void
def _init
@cache = {} #: Hash[untyped, untyped]?
@features_with_metadata = nil #: Hash[String, OptionsMetadata]?
end

#: [Config] (String key, Array[String] feature_names, Class[Config] config_class, Optify::CacheOptions _cache_options, ?Optify::GetOptionsPreferences? preferences) -> Config
def get_options_with_cache(key, feature_names, config_class, _cache_options, preferences = nil)
def _get_options_with_cache(key, feature_names, config_class, _cache_options, preferences = nil)
# Cache directly in Ruby instead of Rust because:
# * Avoid any possible conversion overhead.
# * Memory management: probably better to do it in Ruby for a Ruby app and avoid memory in Rust.
Expand All @@ -100,22 +130,27 @@ def get_options_with_cache(key, feature_names, config_class, _cache_options, pre
# Features are filtered, so we don't need the constraints in the cache key.
are_configurable_strings_enabled = preferences&.are_configurable_strings_enabled? || false
cache_key = [key, feature_names, are_configurable_strings_enabled, config_class]
@cache #: as !nil
.fetch(cache_key) do
# Handle a cache miss.

# We can avoid converting the features names because they're already converted from filtering above, if that was desired.
# We don't need the constraints because we filtered the features above.
# We already know there are no overrides because we checked above.
preferences = GetOptionsPreferences.new
preferences.skip_feature_name_conversion = true
preferences.enable_configurable_strings if are_configurable_strings_enabled

result = _get_options(key, feature_names, config_class, nil, preferences)

@cache #: as !nil
.[]= cache_key, result
ProviderModule._cache_getset(
@cache, #: as !nil
cache_key,
) do
# Handle a cache miss.

# We can avoid converting the features names because they're already converted from filtering above, if that was desired.
# We don't need the constraints because we filtered the features above.
# We already know there are no overrides because we checked above.
cache_miss_preferences = GetOptionsPreferences.new
cache_miss_preferences.skip_feature_name_conversion = true
cache_miss_preferences.enable_configurable_strings if are_configurable_strings_enabled

_get_options(key, feature_names, config_class, nil, cache_miss_preferences)
end
end

#: (?CacheInitOptions?) -> void
def _init(cache_init_options = nil)
@cache = ProviderModule._create_cache(cache_init_options) #: ( Hash[untyped, untyped] | LruRedux::Cache)?
@features_with_metadata = nil #: Hash[String, OptionsMetadata]?
end
end
end
7 changes: 4 additions & 3 deletions ruby/optify/lib/optify_ruby/watcher_implementation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ def get_options(key, feature_names, config_class, cache_options = nil, preferenc
end

# (Optional) Eagerly initializes the cache.
# @param cache_init_options Options for initializing the cache.
# @return [OptionsWatcher] `self`.
#: -> OptionsWatcher
def init
_init
#: (?CacheInitOptions?) -> OptionsWatcher
def init(cache_init_options = nil)
_init(cache_init_options)
@cache_creation_time = Time.now #: Time?
self
end
Expand Down
1 change: 1 addition & 0 deletions ruby/optify/optify.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Gem::Specification.new do |spec|
spec.add_dependency 'rb_sys', '~> 0.9.117'

spec.add_dependency 'optify-from_hash', '~> 0.2.1'
spec.add_dependency 'sin_lru_redux', '~> 2.5.2'

sorbet_version = '>= 0.5'
sorbet_version_upper_bound = '< 1'
Expand Down
9 changes: 9 additions & 0 deletions ruby/optify/sorbet/rbi/gems/sin_lru_redux@2.5.2.rbi

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions ruby/optify/sorbet/rbi/shims/sin_lru_redux.rbi
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# typed: true

module LruRedux
class Cache
sig { params(max_size: Integer).void }
def initialize(max_size); end

sig { params(key: T::Array[T.untyped], block: T.proc.returns(T.untyped)).returns(T.untyped) }
def getset(key, &block); end
end

class ThreadSafeCache < Cache
end
end
5 changes: 4 additions & 1 deletion ruby/optify/test/.rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ inherit_from: ../.rubocop.yml
Metrics/AbcSize:
Max: 45

Metrics/BlockLength:
Max: 27

Metrics/ClassLength:
Max: 256

Metrics/MethodLength:
Max: 40
Max: 40
60 changes: 60 additions & 0 deletions ruby/optify/test/cache_modes_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# typed: true
# frozen_string_literal: true

require 'test/unit'
require 'optify'
require_relative 'my_config'

class CacheModesTest < Test::Unit::TestCase
PROVIDERS = [
Optify::OptionsProvider,
Optify::OptionsWatcher,
].freeze
CACHE_MODES = [
Optify::CacheMode::NOT_THREAD_SAFE,
Optify::CacheMode::THREAD_SAFE,
].freeze

def test_cache_respects_max_size
PROVIDERS.each do |klass|
CACHE_MODES.each do |mode|
cache_init_options = Optify::CacheInitOptions.new(
max_size: 2,
mode: mode,
)
provider = klass.build('../../tests/test_suites/simple/configs')
.init(cache_init_options)
cache_options = Optify::CacheOptions.new

# Cache first two configs
config_a = provider.get_options('myConfig', ['A'], MyConfig, cache_options)
config_b = provider.get_options('myConfig', ['B'], MyConfig, cache_options)

# Verify they are cached
config_a2 = provider.get_options('myConfig', ['A'], MyConfig, cache_options)
config_b2 = provider.get_options('myConfig', ['B'], MyConfig, cache_options)
assert_same(config_a, config_a2, 'config_a should be cached')
assert_same(config_b, config_b2, 'config_b should be cached')

# Add a third config, which should evict the LRU entry (config_a)
config_ab = provider.get_options('myConfig', %w[A B], MyConfig, cache_options)
config_ab2 = provider.get_options('myConfig', %w[A B], MyConfig, cache_options)
assert_same(config_ab, config_ab2, 'config_ab should be cached')

# config_a should have been evicted (LRU), config_b should still be cached
config_b3 = provider.get_options('myConfig', ['B'], MyConfig, cache_options)
assert_same(config_b, config_b3, 'config_b should still be cached')

config_a3 = provider.get_options('myConfig', ['A'], MyConfig, cache_options)
assert_not_same(config_a, config_a3, 'config_a should have been evicted and recreated')

config_a = provider.get_options('myConfig', ['A'], MyConfig, cache_options)
assert_same(config_a, config_a3)

# A B should be evicted
config_ab3 = provider.get_options('myConfig', %w[A B], MyConfig, cache_options)
assert_not_same(config_ab, config_ab3, 'config_ab should have been evicted and recreated')
end
end
end
end