From 6fd48cc223d714007943ce8b6ed0affab60fd241 Mon Sep 17 00:00:00 2001 From: Shinji Nakamatsu <19329+snaka@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:08:03 +0900 Subject: [PATCH] feat: add cross-process cache invalidation - Add CacheInvalidator for cross-process cache invalidation via a Redis-backed global timestamp. - Add cache_global_ts_check_interval configuration option (opt-in; mechanism is disabled when unset). - Clear experiment cache when winner is set. - Add tests for cross-process invalidation and the opt-in default. --- lib/split.rb | 1 + lib/split/cache.rb | 15 ++++- lib/split/cache_invalidator.rb | 71 +++++++++++++++++++++++ lib/split/configuration.rb | 1 + lib/split/experiment.rb | 1 + spec/cache_spec.rb | 100 +++++++++++++++++++++++++++++++-- spec/experiment_spec.rb | 5 ++ 7 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 lib/split/cache_invalidator.rb diff --git a/lib/split.rb b/lib/split.rb index 74d53204..e723b33d 100755 --- a/lib/split.rb +++ b/lib/split.rb @@ -7,6 +7,7 @@ require "split/algorithms/weighted_sample" require "split/algorithms/whiplash" require "split/alternative" +require "split/cache_invalidator" require "split/cache" require "split/configuration" require "split/encapsulated_helper" diff --git a/lib/split/cache.rb b/lib/split/cache.rb index f570af7f..b8fbc4e9 100644 --- a/lib/split/cache.rb +++ b/lib/split/cache.rb @@ -4,21 +4,32 @@ module Split class Cache def self.clear @cache = nil + CacheInvalidator.reset end def self.fetch(namespace, key) return yield unless Split.configuration.cache + # Check global invalidation + CacheInvalidator.check_and_clear_if_needed(self) + @cache ||= {} @cache[namespace] ||= {} value = @cache[namespace][key] - return value if value + unless value + value = yield + @cache[namespace][key] = value + end - @cache[namespace][key] = yield + value end def self.clear_key(key) + # Invalidate globally for all processes + CacheInvalidator.invalidate + + # Clear from local cache immediately @cache&.keys&.each do |namespace| @cache[namespace]&.delete(key) end diff --git a/lib/split/cache_invalidator.rb b/lib/split/cache_invalidator.rb new file mode 100644 index 00000000..160cbea6 --- /dev/null +++ b/lib/split/cache_invalidator.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Split + # Manages global cache invalidation across multiple processes using Redis timestamp. + # The mechanism is opt-in: it activates only when + # Split.configuration.cache_global_ts_check_interval is set. + class CacheInvalidator + class << self + # Check global timestamp and clear cache if it has been updated. + # No-op when cache_global_ts_check_interval is not configured. + # @param cache [Split::Cache] the cache instance to potentially clear + def check_and_clear_if_needed(cache) + return unless check_interval + + now = Time.now + + # Skip if within check interval + return if within_check_interval?(now) + + # Get global timestamp from Redis + current_global_ts = fetch_global_timestamp + + # Clear cache if timestamp was updated + cache.clear if timestamp_updated?(current_global_ts) + + # Update local timestamp and check time + update_local_state(current_global_ts, now) + end + + # Invalidate cache globally by updating the global timestamp. + # No-op when cache_global_ts_check_interval is not configured. + def invalidate + return unless check_interval + + Split.redis.set(global_timestamp_key, Time.now.to_f) + end + + # Reset local state (used when cache is cleared) + def reset + @global_cache_ts = nil + @last_global_ts_check = nil + end + + private + def within_check_interval?(now) + @last_global_ts_check && (now.to_f - @last_global_ts_check.to_f) < check_interval + end + + def fetch_global_timestamp + Split.redis.get(global_timestamp_key).to_f + end + + def timestamp_updated?(current_global_ts) + @global_cache_ts && current_global_ts > @global_cache_ts + end + + def update_local_state(current_global_ts, now) + @global_cache_ts = current_global_ts + @last_global_ts_check = now + end + + def global_timestamp_key + "split:cache:global_ts" + end + + def check_interval + Split.configuration.cache_global_ts_check_interval + end + end + end +end diff --git a/lib/split/configuration.rb b/lib/split/configuration.rb index b43e5920..a48ab4b3 100644 --- a/lib/split/configuration.rb +++ b/lib/split/configuration.rb @@ -30,6 +30,7 @@ class Configuration attr_accessor :redis attr_accessor :dashboard_pagination_default_per_page attr_accessor :cache + attr_accessor :cache_global_ts_check_interval attr_reader :experiments diff --git a/lib/split/experiment.rb b/lib/split/experiment.rb index d91552da..e1a39206 100644 --- a/lib/split/experiment.rb +++ b/lib/split/experiment.rb @@ -153,6 +153,7 @@ def has_winner? def winner=(winner_name) redis.hset(:experiment_winner, name, winner_name.to_s) @has_winner = true + Split::Cache.clear_key(@name) Split.configuration.on_experiment_winner_choose.call(self) end diff --git a/spec/cache_spec.rb b/spec/cache_spec.rb index 48b6cd46..fdebbe40 100644 --- a/spec/cache_spec.rb +++ b/spec/cache_spec.rb @@ -10,9 +10,15 @@ before { allow(Time).to receive(:now).and_return(now) } describe "clear" do - before { Split.configuration.cache = true } + before do + Split.configuration.cache = true + # Disable global invalidation check for this test + allow(Split::CacheInvalidator).to receive(:check_and_clear_if_needed) + end it "clears the cache" do + # First fetch: yield calls Time.now (x1) + # Second fetch: yield calls Time.now (x1) expect(Time).to receive(:now).and_return(now).exactly(2).times Split::Cache.fetch(namespace, key) { Time.now } Split::Cache.clear @@ -21,9 +27,19 @@ end describe "clear_key" do - before { Split.configuration.cache = true } + before do + Split.configuration.cache = true + # Disable global invalidation check for this test + allow(Split::CacheInvalidator).to receive(:check_and_clear_if_needed) + end it "clears the cache" do + # fetch :key1 (miss): yield (x1) + # fetch :key2 (miss): yield (x1) + # clear_key(:key1): no Time.now (invalidate is no-op without interval) + # fetch :key1 (miss again, cleared): yield (x1) + # fetch :key2 (cache hit): 0 + # Total: 3 expect(Time).to receive(:now).and_return(now).exactly(3).times Split::Cache.fetch(namespace, :key1) { Time.now } Split::Cache.fetch(namespace, :key2) { Time.now } @@ -32,6 +48,17 @@ Split::Cache.fetch(namespace, :key1) { Time.now } Split::Cache.fetch(namespace, :key2) { Time.now } end + + it "updates global timestamp in Redis when invalidation is enabled" do + Split.configuration.cache = true + Split.configuration.cache_global_ts_check_interval = 5 + current_time = Time.now.to_f + + expect(Split.redis).to receive(:set).with("split:cache:global_ts", current_time) + allow(Time).to receive(:now).and_return(Time.at(current_time)) + + Split::Cache.clear_key(:some_key) + end end describe "fetch" do @@ -52,14 +79,20 @@ end context "when cache enabled" do - before { Split.configuration.cache = true } + before do + Split.configuration.cache = true + # Disable global invalidation check for this test + allow(Split::CacheInvalidator).to receive(:check_and_clear_if_needed) + end it "returns the yield" do expect(subject).to eql(now) end it "yields once" do - expect(Time).to receive(:now).and_return(now).once + # First fetch: yield calls Time.now (x1) + # Second fetch: cache hit, no yield (0) + expect(Time).to receive(:now).and_return(now).exactly(1).times Split::Cache.fetch(namespace, key) { Time.now } Split::Cache.fetch(namespace, key) { Time.now } end @@ -81,4 +114,63 @@ end end end + + describe "global timestamp invalidation" do + before do + Split.configuration.cache = true + Split.configuration.cache_global_ts_check_interval = 5 + Split::Cache.clear + Split::CacheInvalidator.reset + end + + it "clears cache when global timestamp is updated" do + # Mock Redis to return initial timestamp + allow(Split.redis).to receive(:get).with("split:cache:global_ts").and_return("1000.0") + + # First fetch - should cache the value + result1 = Split::Cache.fetch(namespace, key) { "value1" } + expect(result1).to eq("value1") + + # Simulate another process updating the global timestamp + allow(Split.redis).to receive(:get).with("split:cache:global_ts").and_return("2000.0") + + # Move time forward to bypass check interval + allow(Time).to receive(:now).and_return(Time.at(now + 10)) + + # Second fetch - should get new value because cache was cleared + result2 = Split::Cache.fetch(namespace, key) { "value2" } + expect(result2).to eq("value2") + end + + it "does not check Redis within check interval" do + allow(Split.redis).to receive(:get).with("split:cache:global_ts").and_return("1000.0") + + # First fetch - checks Redis + Split::Cache.fetch(namespace, key) { "value1" } + + # Move time forward by 3 seconds (within interval) + allow(Time).to receive(:now).and_return(Time.at(now + 3)) + + # Second fetch - should NOT check Redis + expect(Split.redis).not_to receive(:get).with("split:cache:global_ts") + Split::Cache.fetch(namespace, key) { "value1" } + end + + context "when cache_global_ts_check_interval is not configured" do + before do + Split.configuration.cache_global_ts_check_interval = nil + Split::Cache.clear + Split::CacheInvalidator.reset + end + + it "does not read or write the global timestamp in Redis" do + expect(Split.redis).not_to receive(:get).with("split:cache:global_ts") + expect(Split.redis).not_to receive(:set).with("split:cache:global_ts", anything) + + Split::Cache.fetch(namespace, key) { "value1" } + Split::Cache.clear_key(:some_key) + Split::Cache.fetch(namespace, key) { "value2" } + end + end + end end diff --git a/spec/experiment_spec.rb b/spec/experiment_spec.rb index 4faf0994..a4f4cfca 100644 --- a/spec/experiment_spec.rb +++ b/spec/experiment_spec.rb @@ -261,6 +261,11 @@ def alternative(color) experiment.winner = "green" end + it "should clear the cache for the experiment" do + expect(Split::Cache).to receive(:clear_key).with(experiment.name) + experiment.winner = "red" + end + context "when has_winner state is memoized" do before { expect(experiment).to_not have_winner }