Skip to content

Commit

Permalink
consistent config caching (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
kp-cat authored Jul 14, 2023
1 parent c44a274 commit 2a73dfc
Show file tree
Hide file tree
Showing 10 changed files with 153 additions and 72 deletions.
4 changes: 0 additions & 4 deletions lib/configcat/configcatclient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,6 @@ def _get_settings
return @_config_service.get_settings()
end

def _get_cache_key
return Digest::SHA1.hexdigest("ruby_" + CONFIG_FILE_NAME + "_" + @_sdk_key)
end

def _evaluate(key, user, default_value, default_variation_id, settings, fetch_time)
user = user || @_default_user
value, variation_id, rule, percentage_rule, error = @_rollout_evaluator.evaluate(
Expand Down
55 changes: 34 additions & 21 deletions lib/configcat/configentry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,50 @@

module ConfigCat
class ConfigEntry
CONFIG = 'config'
ETAG = 'etag'
FETCH_TIME = 'fetch_time'
attr_accessor :config, :etag, :config_json_string, :fetch_time

attr_accessor :config, :etag, :fetch_time

def initialize(config = {}, etag = '', fetch_time = Utils::DISTANT_PAST)
def initialize(config = {}, etag = '', config_json_string = '{}', fetch_time = Utils::DISTANT_PAST)
@config = config
@etag = etag
@config_json_string = config_json_string
@fetch_time = fetch_time
end

def self.create_from_json(json)
return ConfigEntry::EMPTY if json.nil?
return ConfigEntry.new(
config = json.fetch(CONFIG, {}),
etag = json.fetch(ETAG, ''),
fetch_time = json.fetch(FETCH_TIME, Utils::DISTANT_PAST)
)
end

def empty?
self == ConfigEntry::EMPTY
end

def to_json
{
CONFIG => config,
ETAG => etag,
FETCH_TIME => fetch_time
}
def serialize
"#{(fetch_time * 1000).floor}\n#{etag}\n#{config_json_string}"
end

def self.create_from_string(string)
return ConfigEntry.empty if string.nil? || string.empty?

fetch_time_index = string.index("\n")
etag_index = string.index("\n", fetch_time_index + 1)
if fetch_time_index.nil? || etag_index.nil?
raise 'Number of values is fewer than expected.'
end

begin
fetch_time = Float(string[0...fetch_time_index])
rescue ArgumentError
raise "Invalid fetch time: #{string[0...fetch_time_index]}"
end

etag = string[fetch_time_index + 1...etag_index]
if etag.nil? || etag.empty?
raise 'Empty eTag value'
end
begin
config_json = string[etag_index + 1..-1]
config = JSON.parse(config_json)
rescue => e
raise "Invalid config JSON: #{config_json}. #{e.message}"
end

ConfigEntry.new(config, etag, config_json, fetch_time / 1000.0)
end

EMPTY = ConfigEntry.new(etag: 'empty')
Expand Down
2 changes: 1 addition & 1 deletion lib/configcat/configfetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ def _fetch(etag)
response_etag = ""
end
config = JSON.parse(response.body)
return FetchResponse.success(ConfigEntry.new(config, response_etag, Utils.get_utc_now_seconds_since_epoch))
return FetchResponse.success(ConfigEntry.new(config, response_etag, response.body, Utils.get_utc_now_seconds_since_epoch))
when Net::HTTPNotModified
return FetchResponse.not_modified
when Net::HTTPNotFound, Net::HTTPForbidden
Expand Down
11 changes: 7 additions & 4 deletions lib/configcat/configservice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@
module ConfigCat
class ConfigService
def initialize(sdk_key, polling_mode, hooks, config_fetcher, log, config_cache, is_offline)
@sdk_key = sdk_key
@cached_entry = ConfigEntry::EMPTY
@cached_entry_string = ''
@polling_mode = polling_mode
@log = log
@config_cache = config_cache
@hooks = hooks
@cache_key = Digest::SHA1.hexdigest("ruby_#{CONFIG_FILE_NAME}_#{@sdk_key}")
@cache_key = ConfigService.get_cache_key(sdk_key)
@config_fetcher = config_fetcher
@is_offline = is_offline
@response_future = nil
Expand Down Expand Up @@ -107,6 +106,10 @@ def close

private

def self.get_cache_key(sdk_key)
Digest::SHA1.hexdigest("#{sdk_key}_#{CONFIG_FILE_NAME}.json_#{SERIALIZATION_FORMAT_VERSION}")
end

# :return [ConfigEntry, String] Returns the ConfigEntry object and error message in case of any error.
def fetch_if_older(time, prefer_cache: false)
# Sync up with the cache and use it when it's not expired.
Expand Down Expand Up @@ -201,7 +204,7 @@ def read_cache
end

@cached_entry_string = json_string
return ConfigEntry.create_from_json(JSON.parse(json_string))
return ConfigEntry.create_from_string(json_string)
rescue Exception => e
@log.error(2200, "Error occurred while reading the cache. #{e}")
return ConfigEntry::EMPTY
Expand All @@ -210,7 +213,7 @@ def read_cache

def write_cache(config_entry)
begin
@config_cache.set(@cache_key, config_entry.to_json.to_json)
@config_cache.set(@cache_key, config_entry.serialize)
rescue Exception => e
@log.error(2201, "Error occurred while writing the cache. #{e}")
end
Expand Down
1 change: 1 addition & 0 deletions lib/configcat/constants.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module ConfigCat
CONFIG_FILE_NAME = "config_v5"
SERIALIZATION_FORMAT_VERSION = "v2"

PREFERENCES = "p"
BASE_URL = "u"
Expand Down
33 changes: 15 additions & 18 deletions spec/configcat/autopollingcachepolicy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,11 @@
polling_mode = PollingMode.auto_poll(poll_interval_seconds: poll_interval_seconds,
max_init_wait_time_seconds: max_init_wait_time_seconds)
config_fetcher = ConfigFetcherMock.new
config_cache = SingleValueConfigCache.new(
{
ConfigEntry::CONFIG => JSON.parse(TEST_JSON),
ConfigEntry::ETAG => 'test-etag',
ConfigEntry::FETCH_TIME => Utils.get_utc_now_seconds_since_epoch
}.to_json
config_cache = SingleValueConfigCache.new(ConfigEntry.new(
JSON.parse(TEST_JSON),
'test-etag',
TEST_JSON,
Utils.get_utc_now_seconds_since_epoch).serialize
)

start_time = Time.now.utc
Expand Down Expand Up @@ -250,12 +249,11 @@
polling_mode = PollingMode.auto_poll(poll_interval_seconds: poll_interval_seconds,
max_init_wait_time_seconds: max_init_wait_time_seconds)
config_fetcher = ConfigFetcherMock.new
config_cache = SingleValueConfigCache.new(
{
ConfigEntry::CONFIG => JSON.parse(TEST_JSON),
ConfigEntry::ETAG => 'test-etag',
ConfigEntry::FETCH_TIME => Utils.get_utc_now_seconds_since_epoch - poll_interval_seconds
}.to_json
config_cache = SingleValueConfigCache.new(ConfigEntry.new(
JSON.parse(TEST_JSON),
'test-etag',
TEST_JSON,
Utils.get_utc_now_seconds_since_epoch - poll_interval_seconds).serialize
)

hooks = Hooks.new
Expand All @@ -277,12 +275,11 @@
polling_mode = PollingMode.auto_poll(poll_interval_seconds: poll_interval_seconds,
max_init_wait_time_seconds: max_init_wait_time_seconds)
config_fetcher = ConfigFetcherWaitMock.new(5)
config_cache = SingleValueConfigCache.new(
{
ConfigEntry::CONFIG => JSON.parse(TEST_JSON2),
ConfigEntry::ETAG => 'test-etag',
ConfigEntry::FETCH_TIME => Utils.get_utc_now_seconds_since_epoch - 2 * poll_interval_seconds
}.to_json
config_cache = SingleValueConfigCache.new(ConfigEntry.new(
JSON.parse(TEST_JSON2),
'test-etag',
TEST_JSON2,
Utils.get_utc_now_seconds_since_epoch - 2 * poll_interval_seconds).serialize
)

start_time = Time.now.utc
Expand Down
53 changes: 52 additions & 1 deletion spec/configcat/configcache_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require_relative 'mocks'

RSpec.describe ConfigCat::InMemoryConfigCache do
it "test cache" do
it "test_cache" do
config_store = InMemoryConfigCache.new()

value = config_store.get("key")
Expand All @@ -17,4 +17,55 @@
expect(value2).to be nil
end

it "test_cache_key" do
expect(ConfigService.send(:get_cache_key, 'test1')).to eq('147c5b4c2b2d7c77e1605b1a4309f0ea6684a0c6')
expect(ConfigService.send(:get_cache_key, 'test2')).to eq('c09513b1756de9e4bc48815ec7a142b2441ed4d5')
end

it "test_cache_payload" do
now_seconds = 1686756435.8449
etag = 'test-etag'
entry = ConfigEntry.new(JSON.parse(TEST_JSON), etag, TEST_JSON, now_seconds)
expect(entry.serialize).to eq('1686756435844' + "\n" + etag + "\n" + TEST_JSON)
end

it "tests_invalid_cache_content" do
hook_callbacks = HookCallbacks.new
hooks = Hooks.new(on_error: hook_callbacks.method(:on_error))
config_json_string = TEST_JSON_FORMAT % { value: '"test"' }
config_cache = SingleValueConfigCache.new(ConfigEntry.new(
JSON.parse(config_json_string),
'test-etag',
config_json_string,
Utils.get_utc_now_seconds_since_epoch).serialize
)

client = ConfigCatClient.get('test', ConfigCatOptions.new(polling_mode: PollingMode.manual_poll,
config_cache: config_cache,
hooks: hooks))

expect(client.get_value('testKey', 'default')).to eq('test')
expect(hook_callbacks.error_call_count).to eq(0)

# Invalid fetch time in cache
config_cache.value = ['text', 'test-etag', TEST_JSON_FORMAT % { value: '"test2"' }].join("\n")

expect(client.get_value('testKey', 'default')).to eq('test')
expect(hook_callbacks.error).to include('Error occurred while reading the cache. Invalid fetch time: text')

# Number of values is fewer than expected
config_cache.value = [Utils.get_utc_now_seconds_since_epoch.to_s, TEST_JSON_FORMAT % { value: '"test2"' }].join("\n")

expect(client.get_value('testKey', 'default')).to eq('test')
expect(hook_callbacks.error).to include('Error occurred while reading the cache. Number of values is fewer than expected.')

# Invalid config JSON
config_cache.value = [Utils.get_utc_now_seconds_since_epoch.to_s, 'test-etag', 'wrong-json'].join("\n")

expect(client.get_value('testKey', 'default')).to eq('test')
expect(hook_callbacks.error).to include('Error occurred while reading the cache. Invalid config JSON: wrong-json.')

client.close
end

end
24 changes: 11 additions & 13 deletions spec/configcat/lazyloadingcachepolicy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@

it "test_get_skips_hitting_api_after_update_from_different_thread" do
config_fetcher = double("ConfigFetcher")
successful_fetch_response = FetchResponse.success(ConfigEntry.new(JSON.parse(TEST_JSON)))
successful_fetch_response = FetchResponse.success(ConfigEntry.new(JSON.parse(TEST_JSON), '', TEST_JSON))
allow(config_fetcher).to receive(:get_configuration).and_return(successful_fetch_response)

polling_mode = PollingMode.lazy_load(cache_refresh_interval_seconds: 160)
Expand Down Expand Up @@ -117,12 +117,11 @@
it "test_return_cached_config_when_cache_is_not_expired" do
polling_mode = PollingMode.lazy_load(cache_refresh_interval_seconds: 1)
config_fetcher = ConfigFetcherMock.new
config_cache = SingleValueConfigCache.new(
{
ConfigEntry::CONFIG => JSON.parse(TEST_JSON),
ConfigEntry::ETAG => 'test-etag',
ConfigEntry::FETCH_TIME => Utils.get_utc_now_seconds_since_epoch
}.to_json
config_cache = SingleValueConfigCache.new(ConfigEntry.new(
JSON.parse(TEST_JSON),
'test-etag',
TEST_JSON,
Utils.get_utc_now_seconds_since_epoch).serialize
)

hooks = Hooks.new
Expand Down Expand Up @@ -150,12 +149,11 @@
cache_time_to_live_seconds = 1
polling_mode = PollingMode.lazy_load(cache_refresh_interval_seconds: cache_time_to_live_seconds)
config_fetcher = ConfigFetcherMock.new
config_cache = SingleValueConfigCache.new(
{
ConfigEntry::CONFIG => JSON.parse(TEST_JSON),
ConfigEntry::ETAG => 'test-etag',
ConfigEntry::FETCH_TIME => Utils.get_utc_now_seconds_since_epoch - cache_time_to_live_seconds
}.to_json
config_cache = SingleValueConfigCache.new(ConfigEntry.new(
JSON.parse(TEST_JSON),
'test-etag',
TEST_JSON,
Utils.get_utc_now_seconds_since_epoch - cache_time_to_live_seconds).serialize
)

hooks = Hooks.new
Expand Down
25 changes: 23 additions & 2 deletions spec/configcat/manualpollingcachepolicy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@

it "test_cache" do
stub_request = WebMock.stub_request(:get, Regexp.new('https://.*'))
.to_return(status: 200, body: TEST_JSON_FORMAT % { value: '"test"' }, headers: {})
.to_return(status: 200, body: TEST_JSON_FORMAT % { value: '"test"' },
headers: { 'ETag' => 'test-etag' })

polling_mode = PollingMode.manual_poll
hooks = Hooks.new
Expand All @@ -78,21 +79,41 @@
config_cache = InMemoryConfigCache.new
cache_policy = ConfigService.new("", polling_mode, hooks, config_fetcher, logger, config_cache, false)

start_time_milliseconds = (Utils.get_utc_now_seconds_since_epoch * 1000).floor
cache_policy.refresh
settings, _ = cache_policy.get_settings
expect(settings.fetch("testKey").fetch(VALUE)).to eq "test"
expect(stub_request).to have_been_made.times(1)
expect(config_cache.value.length).to eq 1

# Check cache content
cache_tokens = config_cache.value.values[0].split("\n")
expect(cache_tokens.length).to eq(3)
expect(start_time_milliseconds).to be <= cache_tokens[0].to_f
expect((Utils.get_utc_now_seconds_since_epoch * 1000).floor).to be >= cache_tokens[0].to_f
expect(cache_tokens[1]).to eq('test-etag')
expect(cache_tokens[2]).to eq(TEST_JSON_FORMAT % { value: '"test"' })

# Update response
WebMock.stub_request(:get, Regexp.new('https://.*'))
.to_return(status: 200, body: TEST_JSON_FORMAT % { value: '"test2"' }, headers: {})
.to_return(status: 200, body: TEST_JSON_FORMAT % { value: '"test2"' },
headers: { 'ETag' => 'test-etag' })

start_time_milliseconds = (Utils.get_utc_now_seconds_since_epoch * 1000).floor
cache_policy.refresh
settings, _ = cache_policy.get_settings
expect(settings.fetch("testKey").fetch(VALUE)).to eq "test2"
expect(stub_request).to have_been_made.times(2)
expect(config_cache.value.length).to eq 1

# Check cache content
cache_tokens = config_cache.value.values[0].split("\n")
expect(cache_tokens.length).to eq(3)
expect(start_time_milliseconds).to be <= cache_tokens[0].to_f
expect((Utils.get_utc_now_seconds_since_epoch * 1000).floor).to be >= cache_tokens[0].to_f
expect(cache_tokens[1]).to eq('test-etag')
expect(cache_tokens[2]).to eq(TEST_JSON_FORMAT % { value: '"test2"' })

cache_policy.close
end

Expand Down
Loading

0 comments on commit 2a73dfc

Please sign in to comment.