diff --git a/README.md b/README.md index 04b12d5..a20dc9d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This cache store is designed to be committed to version control, making it ideal ## Features - **File-based storage**: Each cache entry is stored as separate `.key` and `.value` files -- **Hashed filenames**: Uses SHA256 hashing for keys to create consistent, filesystem-safe filenames +- **Hashed filenames**: Uses ActiveSupport::Digest for keys to create consistent, filesystem-safe filenames - **No expiration**: Cache entries do NOT honor expiration parameters - they persist until explicitly deleted - **Rails 7.1+ compatible**: Implements the ActiveSupport::Cache::Store interface @@ -68,11 +68,43 @@ cache.delete("my_key") cache.clear ``` +### Subdirectory Delimiter + +You can optionally configure a `subdirectory_delimiter` to organize cache entries into nested subdirectories based on key segments: + +```ruby +cache = ActiveSupport::Cache::SourceControlCacheStore.new( + cache_path: "/path/to/cache/directory", + subdirectory_delimiter: "---" +) + +# With delimiter "---", key "foo---bar---boo-ba" creates: +# /path/to/cache/directory/ +# hash(foo)/ +# _key_chunk (contains "foo") +# hash(bar)/ +# _key_chunk (contains "bar") +# hash(boo-ba)/ +# _key_chunk (contains "boo-ba") +# value (contains the cached value) + +cache.write("foo---bar---boo-ba", "27") +value = cache.read("foo---bar---boo-ba") # => "27" +``` + +When a delimiter is configured: +- The cache key is split by the delimiter into segments +- Each segment creates a subdirectory named `hash(segment)` using ActiveSupport::Digest +- Each subdirectory contains a `_key_chunk` file with the original segment text +- The cached value is stored in a `value` file in the final subdirectory + +This feature is useful for organizing cache entries hierarchically when keys have a natural structure. + ## Key Features ### Hashed Keys -Keys are hashed using SHA256 to create filesystem-safe filenames. The original key is preserved in the `.key` file, while the hash is used for the filename: +Keys are hashed using ActiveSupport::Digest to create filesystem-safe filenames. The original key is preserved in the `.key` file, while the hash is used for the filename: ```ruby cache.write("user:123:profile", { name: "John" }) diff --git a/lib/active_support/cache/source_control_cache_store.rb b/lib/active_support/cache/source_control_cache_store.rb index 62421d1..1604d92 100644 --- a/lib/active_support/cache/source_control_cache_store.rb +++ b/lib/active_support/cache/source_control_cache_store.rb @@ -4,7 +4,6 @@ require "active_support/cache" require "active_support/notifications" require "active_support/core_ext/object/json" -require "digest" require "fileutils" module ActiveSupport @@ -19,23 +18,29 @@ module Cache # Example usage: # config.cache_store = :source_control_cache_store, cache_path: "tmp/cache" class SourceControlCacheStore < Store - attr_reader :cache_path + attr_reader :cache_path, :subdirectory_delimiter # Initialize a new SourceControlCacheStore # # @param cache_path [String] The directory where cache files will be stored + # @param subdirectory_delimiter [String, nil] Optional delimiter to split keys into subdirectories # @param options [Hash] Additional options (currently unused) - def initialize(cache_path:, **options) + def initialize(cache_path:, subdirectory_delimiter: nil, **options) super(options) @cache_path = cache_path + @subdirectory_delimiter = subdirectory_delimiter FileUtils.mkdir_p(@cache_path) end # Clear all cache entries def clear(options = nil) if File.directory?(@cache_path) - Dir.glob(File.join(@cache_path, "*")).each do |file| - File.delete(file) if File.file?(file) + Dir.glob(File.join(@cache_path, "*")).each do |path| + if File.file?(path) + File.delete(path) + elsif File.directory?(path) + FileUtils.rm_rf(path) + end end end true @@ -49,8 +54,7 @@ def clear(options = nil) # @param options [Hash] Options (unused) # @return [Object, nil] The cached value or nil if not found def read_entry(key, **options) - hash = hash_key(key) - value_file = value_path(hash) + value_file = value_path_for_key(key) return nil unless File.exist?(value_file) @@ -74,6 +78,23 @@ def read_entry(key, **options) # @param options [Hash] Options (expiration is ignored) # @return [Boolean] Returns true on success, false on failure def write_entry(key, entry, **options) + if @subdirectory_delimiter + write_entry_with_subdirectories(key, entry, **options) + else + write_entry_simple(key, entry, **options) + end + rescue StandardError + # Return false if write fails (permissions, disk space, etc.) + false + end + + # Write entry using simple hash-based file structure + # + # @param key [String] The cache key + # @param entry [ActiveSupport::Cache::Entry] The cache entry + # @param options [Hash] Options (expiration is ignored) + # @return [Boolean] Returns true on success + def write_entry_simple(key, entry, **options) hash = hash_key(key) # Write the key file @@ -83,9 +104,32 @@ def write_entry(key, entry, **options) File.write(value_path(hash), serialize_entry(entry, **options)) true - rescue StandardError - # Return false if write fails (permissions, disk space, etc.) - false + end + + # Write entry using subdirectory structure + # + # @param key [String] The cache key + # @param entry [ActiveSupport::Cache::Entry] The cache entry + # @param options [Hash] Options (expiration is ignored) + # @return [Boolean] Returns true on success + def write_entry_with_subdirectories(key, entry, **options) + chunks = key.to_s.split(@subdirectory_delimiter) + current_dir = @cache_path + + # Create subdirectories for each chunk + chunks.each do |chunk| + chunk_hash = hash_chunk(chunk) + current_dir = File.join(current_dir, chunk_hash) + FileUtils.mkdir_p(current_dir) + + # Write _key_chunk file + File.write(File.join(current_dir, "_key_chunk"), chunk) + end + + # Write the value file in the final directory + File.write(File.join(current_dir, "value"), serialize_entry(entry, **options)) + + true end # Delete an entry from the cache @@ -94,6 +138,15 @@ def write_entry(key, entry, **options) # @param options [Hash] Options (unused) # @return [Boolean] Returns true if any file was deleted def delete_entry(key, **options) + if @subdirectory_delimiter + delete_entry_with_subdirectories(key, **options) + else + delete_entry_simple(key, **options) + end + end + + # Delete entry using simple hash-based file structure + def delete_entry_simple(key, **options) hash = hash_key(key) key_file = key_path(hash) value_file = value_path(hash) @@ -115,12 +168,42 @@ def delete_entry(key, **options) deleted end + # Delete entry using subdirectory structure + # + # @param key [String] The cache key + # @param options [Hash] Options (unused) + # @return [Boolean] Returns true if the entry was deleted + def delete_entry_with_subdirectories(key, **options) + value_file = value_path_for_key(key) + + return false unless File.exist?(value_file) + + # Delete only the deepest directory containing this specific entry + current_dir = subdirectory_path_for_key(key) + + begin + # Delete the final directory (containing _key_chunk and value) + FileUtils.rm_rf(current_dir) if File.exist?(current_dir) + true + rescue StandardError + false + end + end + # Generate a hash for the given key # # @param key [String] The cache key - # @return [String] The SHA256 hash of the key + # @return [String] The hash of the key def hash_key(key) - ::Digest::SHA256.hexdigest(key.to_s) + ::ActiveSupport::Digest.hexdigest(key.to_s) + end + + # Generate a hash for a key chunk + # + # @param chunk [String] A chunk of the cache key + # @return [String] The hash of the chunk + def hash_chunk(chunk) + ::ActiveSupport::Digest.hexdigest(chunk.to_s) end # Get the path for the key file @@ -138,6 +221,34 @@ def key_path(hash) def value_path(hash) File.join(@cache_path, "#{hash}.value") end + + # Get the value file path for a given key + # + # @param key [String] The cache key + # @return [String] The full path to the value file + def value_path_for_key(key) + if @subdirectory_delimiter + File.join(subdirectory_path_for_key(key), "value") + else + value_path(hash_key(key)) + end + end + + # Get the subdirectory path for a given key + # + # @param key [String] The cache key + # @return [String] The full path to the subdirectory for this key + def subdirectory_path_for_key(key) + chunks = key.to_s.split(@subdirectory_delimiter) + current_dir = @cache_path + + chunks.each do |chunk| + chunk_hash = hash_chunk(chunk) + current_dir = File.join(current_dir, chunk_hash) + end + + current_dir + end end end end diff --git a/spec/source_control_cache_store_spec.rb b/spec/source_control_cache_store_spec.rb index 7b8f1ed..37aec00 100644 --- a/spec/source_control_cache_store_spec.rb +++ b/spec/source_control_cache_store_spec.rb @@ -45,7 +45,7 @@ store.write("my_key", "my_value") # Calculate the expected hash - hash = Digest::SHA256.hexdigest("my_key") + hash = ActiveSupport::Digest.hexdigest("my_key") key_file = File.join(cache_path, "#{hash}.key") value_file = File.join(cache_path, "#{hash}.value") @@ -57,7 +57,7 @@ original_key = "my_special_key" store.write(original_key, "value") - hash = Digest::SHA256.hexdigest(original_key) + hash = ActiveSupport::Digest.hexdigest(original_key) key_file = File.join(cache_path, "#{hash}.key") expect(File.read(key_file)).to eq(original_key) @@ -82,7 +82,7 @@ it "removes both .key and .value files" do store.write("key", "value") - hash = Digest::SHA256.hexdigest("key") + hash = ActiveSupport::Digest.hexdigest("key") key_file = File.join(cache_path, "#{hash}.key") value_file = File.join(cache_path, "#{hash}.value") @@ -149,9 +149,9 @@ end describe "key hashing" do - it "uses SHA256 for hashing keys" do + it "uses ActiveSupport::Digest for hashing keys" do key = "test_key" - expected_hash = Digest::SHA256.hexdigest(key) + expected_hash = ActiveSupport::Digest.hexdigest(key) store.write(key, "value") @@ -198,4 +198,138 @@ expect(store.read("false_key")).to eq(false) end end + + describe "subdirectory_delimiter feature" do + let(:cache_path_with_delimiter) { Dir.mktmpdir } + let(:store_with_delimiter) { described_class.new(cache_path: cache_path_with_delimiter, subdirectory_delimiter: "---") } + + after do + FileUtils.rm_rf(cache_path_with_delimiter) if File.exist?(cache_path_with_delimiter) + end + + it "stores subdirectory_delimiter parameter" do + expect(store_with_delimiter.subdirectory_delimiter).to eq("---") + end + + it "creates nested directories for split keys" do + store_with_delimiter.write("foo---bar---boo-ba", "27") + + # Calculate expected hashes + foo_hash = ActiveSupport::Digest.hexdigest("foo") + bar_hash = ActiveSupport::Digest.hexdigest("bar") + boo_ba_hash = ActiveSupport::Digest.hexdigest("boo-ba") + + # Check that directories exist + expect(File.directory?(File.join(cache_path_with_delimiter, foo_hash))).to be true + expect(File.directory?(File.join(cache_path_with_delimiter, foo_hash, bar_hash))).to be true + expect(File.directory?(File.join(cache_path_with_delimiter, foo_hash, bar_hash, boo_ba_hash))).to be true + end + + it "creates _key_chunk files with correct content" do + store_with_delimiter.write("foo---bar---boo-ba", "27") + + foo_hash = ActiveSupport::Digest.hexdigest("foo") + bar_hash = ActiveSupport::Digest.hexdigest("bar") + boo_ba_hash = ActiveSupport::Digest.hexdigest("boo-ba") + + # Check _key_chunk files + foo_chunk_file = File.join(cache_path_with_delimiter, foo_hash, "_key_chunk") + bar_chunk_file = File.join(cache_path_with_delimiter, foo_hash, bar_hash, "_key_chunk") + boo_ba_chunk_file = File.join(cache_path_with_delimiter, foo_hash, bar_hash, boo_ba_hash, "_key_chunk") + + expect(File.read(foo_chunk_file)).to eq("foo") + expect(File.read(bar_chunk_file)).to eq("bar") + expect(File.read(boo_ba_chunk_file)).to eq("boo-ba") + end + + it "stores value in the final directory" do + store_with_delimiter.write("foo---bar---boo-ba", "27") + + foo_hash = ActiveSupport::Digest.hexdigest("foo") + bar_hash = ActiveSupport::Digest.hexdigest("bar") + boo_ba_hash = ActiveSupport::Digest.hexdigest("boo-ba") + + value_file = File.join(cache_path_with_delimiter, foo_hash, bar_hash, boo_ba_hash, "value") + + expect(File.exist?(value_file)).to be true + expect(store_with_delimiter.read("foo---bar---boo-ba")).to eq("27") + end + + it "reads values correctly from subdirectory structure" do + store_with_delimiter.write("alpha---beta", "test_value") + expect(store_with_delimiter.read("alpha---beta")).to eq("test_value") + end + + it "handles single chunk keys (no delimiter present)" do + store_with_delimiter.write("single_key", "single_value") + + single_hash = ActiveSupport::Digest.hexdigest("single_key") + value_file = File.join(cache_path_with_delimiter, single_hash, "value") + + expect(File.exist?(value_file)).to be true + expect(store_with_delimiter.read("single_key")).to eq("single_value") + end + + it "deletes entries in subdirectory structure" do + store_with_delimiter.write("foo---bar---baz", "value") + expect(store_with_delimiter.read("foo---bar---baz")).to eq("value") + + result = store_with_delimiter.delete("foo---bar---baz") + expect(result).to be_truthy + expect(store_with_delimiter.read("foo---bar---baz")).to be_nil + end + + it "clears all entries including subdirectories" do + store_with_delimiter.write("key1---sub1", "value1") + store_with_delimiter.write("key2---sub2", "value2") + store_with_delimiter.write("key3---sub3---sub4", "value3") + + expect(Dir.glob(File.join(cache_path_with_delimiter, "*")).length).to be > 0 + + store_with_delimiter.clear + + expect(Dir.glob(File.join(cache_path_with_delimiter, "*")).length).to eq(0) + end + + it "uses fetch correctly with subdirectory structure" do + result = store_with_delimiter.fetch("new---key") { "computed" } + expect(result).to eq("computed") + expect(store_with_delimiter.read("new---key")).to eq("computed") + end + + it "overwrites existing values in subdirectory structure" do + store_with_delimiter.write("key---sub", "value1") + store_with_delimiter.write("key---sub", "value2") + expect(store_with_delimiter.read("key---sub")).to eq("value2") + end + + it "handles complex objects in subdirectory structure" do + complex_object = { name: "Test", data: [1, 2, 3] } + store_with_delimiter.write("obj---data", complex_object) + expect(store_with_delimiter.read("obj---data")).to eq(complex_object) + end + + it "handles many levels of nesting" do + key = "a---b---c---d---e---f" + store_with_delimiter.write(key, "deep_value") + expect(store_with_delimiter.read(key)).to eq("deep_value") + end + + it "deletes only the specific entry without affecting others with common prefixes" do + # Write two keys that share the first chunk + store_with_delimiter.write("foo---bar", "value1") + store_with_delimiter.write("foo---baz", "value2") + + # Verify both exist + expect(store_with_delimiter.read("foo---bar")).to eq("value1") + expect(store_with_delimiter.read("foo---baz")).to eq("value2") + + # Delete the first one + store_with_delimiter.delete("foo---bar") + + # Verify only the deleted one is gone + expect(store_with_delimiter.read("foo---bar")).to be_nil + expect(store_with_delimiter.read("foo---baz")).to eq("value2") + end + end end