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
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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" })
Expand Down
135 changes: 123 additions & 12 deletions lib/active_support/cache/source_control_cache_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Loading