From bd91fd3397d2709d67bf71765d184495bf5b4c7f Mon Sep 17 00:00:00 2001 From: Huy Le Date: Thu, 23 Oct 2025 14:49:03 +0700 Subject: [PATCH] Add redis store layer --- README.md | 52 ++++++++ lib/request_store.rb | 32 ++++- lib/request_store/configuration.rb | 29 +++++ lib/request_store/redis_store.rb | 142 +++++++++++++++++++++ request_store.gemspec | 2 + test/redis_store_test.rb | 194 +++++++++++++++++++++++++++++ 6 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 lib/request_store/configuration.rb create mode 100644 lib/request_store/redis_store.rb create mode 100644 test/redis_store_test.rb diff --git a/README.md b/README.md index abf4325..4e1a1a5 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,58 @@ top_posts = RequestStore.fetch(:top_posts) do end ``` +### Redis Caching (Optional) + +You can add Redis as a persistent cache layer. Think of it like this: + +``` +Your app → RequestStore (super fast, but cleared each request) + → Redis (fast, persists between requests) + → Database (slow) +``` + +#### Why bother? + +Say you call `User.find(123)` ten times in one request. Without RequestStore, that's ten Redis calls. With it, you get one Redis call and nine instant memory reads. + +And between requests? Redis keeps your data warm so you're not hitting the database every time. + +#### Setup + +```ruby +# config/initializers/request_store.rb +require 'redis' + +RequestStore.configure do |config| + config.redis_client = Redis.new(url: ENV['REDIS_URL']) + config.redis_enabled = true + config.redis_prefix = "myapp" + config.redis_ttl = 604800 # 1 week +end +``` + +#### Example + +```ruby +def current_user + RequestStore.fetch("user:#{session[:user_id]}") do + # Only runs if not in RequestStore or Redis + User.find(session[:user_id]) + end +end + +# First call in request: checks RequestStore (miss) → checks Redis → maybe hits DB +# Second call in same request: instant memory lookup +# First call in next request: checks RequestStore (miss) → hits Redis (no DB!) +``` + +#### Notes + +- RequestStore clears after every request (that's the point) +- Redis sticks around between requests (default: 1 week) +- Set `redis_ttl` to whatever makes sense (week, month, or nil for forever) +- It fails gracefully - if Redis is down, you just get slower requests + ### Rails 2 compatibility The gem includes a Railtie that will configure everything properly for Rails 3+ diff --git a/lib/request_store.rb b/lib/request_store.rb index 7a76460..1b74d16 100644 --- a/lib/request_store.rb +++ b/lib/request_store.rb @@ -1,4 +1,6 @@ require "request_store/version" +require "request_store/configuration" +require "request_store/redis_store" require "request_store/middleware" require "request_store/railtie" if defined?(Rails::Railtie) @@ -12,6 +14,7 @@ def self.store=(store) end def self.clear! + RedisStore.clear! if configuration.redis_enabled? Thread.current[:request_store] = {} end @@ -28,31 +31,50 @@ def self.active? end def self.read(key) + # Check thread-local store first + return store[key] if store.key?(key) + + # Fall back to Redis if enabled + if configuration.redis_enabled? + value = RedisStore.read(key) + store[key] = value if value # Cache in thread-local store + return value + end + store[key] end def self.[](key) - store[key] + read(key) end def self.write(key, value) store[key] = value + RedisStore.write(key, value) if configuration.redis_enabled? + value end def self.[]=(key, value) - store[key] = value + write(key, value) end def self.exist?(key) - store.key?(key) + return true if store.key?(key) + configuration.redis_enabled? && RedisStore.exist?(key) end def self.fetch(key) - store[key] = yield unless exist?(key) - store[key] + if exist?(key) + read(key) + else + value = yield + write(key, value) + value + end end def self.delete(key, &block) + RedisStore.delete(key) if configuration.redis_enabled? store.delete(key, &block) end end diff --git a/lib/request_store/configuration.rb b/lib/request_store/configuration.rb new file mode 100644 index 0000000..ca02f8d --- /dev/null +++ b/lib/request_store/configuration.rb @@ -0,0 +1,29 @@ +module RequestStore + class Configuration + attr_accessor :redis_client, :redis_enabled, :redis_prefix, :redis_ttl, :skip_redis_on_error + + def initialize + @redis_client = nil + @redis_enabled = false + @redis_prefix = "request_store" + @redis_ttl = 604800 # 1 week default TTL (in seconds) + @skip_redis_on_error = true # Fail gracefully by default + end + + def redis_enabled? + @redis_enabled && !@redis_client.nil? + end + end + + def self.configuration + @configuration ||= Configuration.new + end + + def self.configure + yield(configuration) + end + + def self.reset_configuration! + @configuration = Configuration.new + end +end diff --git a/lib/request_store/redis_store.rb b/lib/request_store/redis_store.rb new file mode 100644 index 0000000..eada766 --- /dev/null +++ b/lib/request_store/redis_store.rb @@ -0,0 +1,142 @@ +module RequestStore + module RedisStore + class << self + # Generate Redis key with namespace prefix + def redis_key(key) + "#{RequestStore.configuration.redis_prefix}:#{key}" + end + + # Read from Redis cache + def read(key) + return nil unless RequestStore.configuration.redis_enabled? + + begin + value = RequestStore.configuration.redis_client.get(redis_key(key)) + deserialize(value) + rescue => e + handle_error(e, "reading from Redis") + nil + end + end + + # Write to Redis cache + def write(key, value) + return value unless RequestStore.configuration.redis_enabled? + + begin + serialized = serialize(value) + r_key = redis_key(key) + + RequestStore.configuration.redis_client.set(r_key, serialized) + + # Set TTL if configured + if RequestStore.configuration.redis_ttl + RequestStore.configuration.redis_client.expire(r_key, RequestStore.configuration.redis_ttl) + end + + value + rescue => e + handle_error(e, "writing to Redis") + value + end + end + + # Check if key exists in Redis + def exist?(key) + return false unless RequestStore.configuration.redis_enabled? + + begin + RequestStore.configuration.redis_client.exists(redis_key(key)) > 0 + rescue => e + handle_error(e, "checking existence in Redis") + false + end + end + + # Delete from Redis cache + def delete(key) + return nil unless RequestStore.configuration.redis_enabled? + + begin + value = read(key) + RequestStore.configuration.redis_client.del(redis_key(key)) + value + rescue => e + handle_error(e, "deleting from Redis") + nil + end + end + + # Clear all Redis data - this clears ALL keys with the prefix + # Use with caution in production! + def clear! + return unless RequestStore.configuration.redis_enabled? + + begin + # Get all keys with our prefix and delete them + pattern = "#{RequestStore.configuration.redis_prefix}:*" + keys = RequestStore.configuration.redis_client.keys(pattern) + RequestStore.configuration.redis_client.del(*keys) if keys.any? + rescue => e + handle_error(e, "clearing Redis cache") + end + end + + # Get all keys with the configured prefix + def keys + return [] unless RequestStore.configuration.redis_enabled? + + begin + pattern = "#{RequestStore.configuration.redis_prefix}:*" + RequestStore.configuration.redis_client.keys(pattern).map do |k| + k.sub("#{RequestStore.configuration.redis_prefix}:", '') + end + rescue => e + handle_error(e, "getting keys from Redis") + [] + end + end + + # Get all data as a hash (can be expensive!) + def all + return {} unless RequestStore.configuration.redis_enabled? + + begin + result = {} + keys.each do |key| + result[key] = read(key) + end + result + rescue => e + handle_error(e, "getting all data from Redis") + {} + end + end + + private + + def serialize(value) + Marshal.dump(value) + rescue => _e + # Fall back to string representation if Marshal fails + value.to_s + end + + def deserialize(value) + return nil if value.nil? + Marshal.load(value) + rescue => _e + # If unmarshal fails, return the raw value + value + end + + def handle_error(error, operation) + if RequestStore.configuration.skip_redis_on_error + warn "RequestStore Redis error while #{operation}: #{error.message}" + else + raise error + end + end + end + end +end diff --git a/request_store.gemspec b/request_store.gemspec index 4cbf54c..0889bc1 100644 --- a/request_store.gemspec +++ b/request_store.gemspec @@ -21,4 +21,6 @@ Gem::Specification.new do |gem| gem.add_development_dependency "rake" gem.add_development_dependency "minitest", "~> 5.0" + gem.add_development_dependency "redis", ">= 4.0" + gem.add_development_dependency "mock_redis", "~> 0.36.0" end diff --git a/test/redis_store_test.rb b/test/redis_store_test.rb new file mode 100644 index 0000000..702144f --- /dev/null +++ b/test/redis_store_test.rb @@ -0,0 +1,194 @@ +require 'minitest/autorun' +require 'request_store' +require 'mock_redis' + +class RedisStoreTest < Minitest::Test + def setup + RequestStore.clear! + @mock_redis = MockRedis.new + RequestStore.configure do |config| + config.redis_client = @mock_redis + config.redis_enabled = true + config.redis_prefix = "test_rs" + config.redis_ttl = 604800 # 1 week + end + end + + def teardown + RequestStore.reset_configuration! + RequestStore.clear! + end + + def test_write_stores_in_redis + RequestStore.write(:foo, "bar") + + # Verify it's in thread-local store + assert_equal "bar", RequestStore.store[:foo] + + # Verify it's in Redis with proper key + redis_key = RequestStore::RedisStore.redis_key(:foo) + assert_equal "test_rs:foo", redis_key + assert_equal "bar", Marshal.load(@mock_redis.get(redis_key)) + end + + def test_read_from_redis_when_not_in_thread_local + # Write directly to Redis, bypassing thread-local store + redis_key = RequestStore::RedisStore.redis_key(:foo) + @mock_redis.set(redis_key, Marshal.dump("bar")) + + # Clear thread-local store + RequestStore.store.clear + + # Read should fetch from Redis and cache locally + assert_equal "bar", RequestStore.read(:foo) + assert_equal "bar", RequestStore.store[:foo] + end + + def test_read_from_thread_local_first + # Set in both thread-local and Redis with different values + RequestStore.store[:foo] = "thread_local_value" + redis_key = RequestStore::RedisStore.redis_key(:foo) + @mock_redis.set(redis_key, Marshal.dump("redis_value")) + + # Should return thread-local value (faster) + assert_equal "thread_local_value", RequestStore.read(:foo) + end + + def test_exist_checks_redis + RequestStore.write(:foo, "bar") + assert RequestStore.exist?(:foo) + + # Clear thread-local but not Redis + RequestStore.store.clear + + # Should still exist in Redis + assert RequestStore.exist?(:foo) + end + + def test_delete_removes_from_redis + RequestStore.write(:foo, "bar") + redis_key = RequestStore::RedisStore.redis_key(:foo) + + assert_equal 1, @mock_redis.exists(redis_key) + + RequestStore.delete(:foo) + + assert_equal 0, @mock_redis.exists(redis_key) + end + + def test_clear_removes_all_from_redis + RequestStore.write(:foo, "bar") + RequestStore.write(:baz, "qux") + + # Verify both keys exist + assert_equal 1, @mock_redis.exists("test_rs:foo") + assert_equal 1, @mock_redis.exists("test_rs:baz") + + RequestStore.clear! + + # Both should be deleted + assert_equal 0, @mock_redis.exists("test_rs:foo") + assert_equal 0, @mock_redis.exists("test_rs:baz") + end + + def test_fetch_with_redis + value = RequestStore.fetch(:computed) { "expensive_result" } + + assert_equal "expensive_result", value + + # Clear thread-local + RequestStore.store.clear + + # Should fetch from Redis without calling block + called = false + value = RequestStore.fetch(:computed) { called = true; "should_not_use" } + + assert_equal "expensive_result", value + refute called + end + + def test_bracket_accessor_uses_redis + RequestStore[:foo] = "bar" + + # Verify in Redis + redis_key = RequestStore::RedisStore.redis_key(:foo) + assert_equal 1, @mock_redis.exists(redis_key) + + # Clear thread-local + RequestStore.store.clear + + # Bracket read should fetch from Redis + assert_equal "bar", RequestStore[:foo] + end + + def test_redis_ttl_is_set + RequestStore.write(:foo, "bar") + + redis_key = RequestStore::RedisStore.redis_key(:foo) + ttl = @mock_redis.ttl(redis_key) + + assert ttl > 0 + assert ttl <= 604800 # 1 week + end + + def test_works_without_redis + RequestStore.configure do |config| + config.redis_enabled = false + end + + RequestStore.write(:foo, "bar") + assert_equal "bar", RequestStore.read(:foo) + + # Redis should be empty + redis_key = RequestStore::RedisStore.redis_key(:foo) + assert_equal 0, @mock_redis.exists(redis_key) + end + + def test_handles_complex_objects + complex_obj = { nested: { data: [1, 2, 3] }, string: "test" } + RequestStore.write(:complex, complex_obj) + + # Clear thread-local + RequestStore.store.clear + + # Should deserialize correctly from Redis + retrieved = RequestStore.read(:complex) + assert_equal complex_obj, retrieved + end + + def test_graceful_error_handling + # Create a mock redis that raises error on set + bad_redis = MockRedis.new + def bad_redis.set(*args) + raise Redis::BaseError.new("Connection failed") + end + + RequestStore.configure do |config| + config.redis_client = bad_redis + config.redis_enabled = true + config.skip_redis_on_error = true + end + + # Should not raise, just warn + assert_output(nil, /Redis error/) do + RequestStore.write(:foo, "bar") + end + + # Should still be in thread-local store + assert_equal "bar", RequestStore.store[:foo] + end + + def test_configuration + assert_equal @mock_redis, RequestStore.configuration.redis_client + assert RequestStore.configuration.redis_enabled? + assert_equal "test_rs", RequestStore.configuration.redis_prefix + assert_equal 604800, RequestStore.configuration.redis_ttl + end + + def test_reset_configuration + RequestStore.reset_configuration! + + refute RequestStore.configuration.redis_enabled? + assert_nil RequestStore.configuration.redis_client + end +end