Skip to content

Commit 05907e7

Browse files
Copilotnhorton
andauthored
[WIP] Modify cache store to support subdirectory delimiter (#2)
* Initial plan * Add subdirectory delimiter feature with tests and documentation Co-authored-by: nhorton <[email protected]> * Replace SHA256 with ActiveSupport::Digest and fix deletion logic Co-authored-by: nhorton <[email protected]> * Add docstrings and refactor to reduce code duplication Co-authored-by: nhorton <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: nhorton <[email protected]>
1 parent eb12d0a commit 05907e7

File tree

3 files changed

+296
-19
lines changed

3 files changed

+296
-19
lines changed

README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ This cache store is designed to be committed to version control, making it ideal
1414
## Features
1515

1616
- **File-based storage**: Each cache entry is stored as separate `.key` and `.value` files
17-
- **Hashed filenames**: Uses SHA256 hashing for keys to create consistent, filesystem-safe filenames
17+
- **Hashed filenames**: Uses ActiveSupport::Digest for keys to create consistent, filesystem-safe filenames
1818
- **No expiration**: Cache entries do NOT honor expiration parameters - they persist until explicitly deleted
1919
- **Rails 7.1+ compatible**: Implements the ActiveSupport::Cache::Store interface
2020

@@ -68,11 +68,43 @@ cache.delete("my_key")
6868
cache.clear
6969
```
7070

71+
### Subdirectory Delimiter
72+
73+
You can optionally configure a `subdirectory_delimiter` to organize cache entries into nested subdirectories based on key segments:
74+
75+
```ruby
76+
cache = ActiveSupport::Cache::SourceControlCacheStore.new(
77+
cache_path: "/path/to/cache/directory",
78+
subdirectory_delimiter: "---"
79+
)
80+
81+
# With delimiter "---", key "foo---bar---boo-ba" creates:
82+
# /path/to/cache/directory/
83+
# hash(foo)/
84+
# _key_chunk (contains "foo")
85+
# hash(bar)/
86+
# _key_chunk (contains "bar")
87+
# hash(boo-ba)/
88+
# _key_chunk (contains "boo-ba")
89+
# value (contains the cached value)
90+
91+
cache.write("foo---bar---boo-ba", "27")
92+
value = cache.read("foo---bar---boo-ba") # => "27"
93+
```
94+
95+
When a delimiter is configured:
96+
- The cache key is split by the delimiter into segments
97+
- Each segment creates a subdirectory named `hash(segment)` using ActiveSupport::Digest
98+
- Each subdirectory contains a `_key_chunk` file with the original segment text
99+
- The cached value is stored in a `value` file in the final subdirectory
100+
101+
This feature is useful for organizing cache entries hierarchically when keys have a natural structure.
102+
71103
## Key Features
72104

73105
### Hashed Keys
74106

75-
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:
107+
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:
76108

77109
```ruby
78110
cache.write("user:123:profile", { name: "John" })

lib/active_support/cache/source_control_cache_store.rb

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
require "active_support/cache"
55
require "active_support/notifications"
66
require "active_support/core_ext/object/json"
7-
require "digest"
87
require "fileutils"
98

109
module ActiveSupport
@@ -19,23 +18,29 @@ module Cache
1918
# Example usage:
2019
# config.cache_store = :source_control_cache_store, cache_path: "tmp/cache"
2120
class SourceControlCacheStore < Store
22-
attr_reader :cache_path
21+
attr_reader :cache_path, :subdirectory_delimiter
2322

2423
# Initialize a new SourceControlCacheStore
2524
#
2625
# @param cache_path [String] The directory where cache files will be stored
26+
# @param subdirectory_delimiter [String, nil] Optional delimiter to split keys into subdirectories
2727
# @param options [Hash] Additional options (currently unused)
28-
def initialize(cache_path:, **options)
28+
def initialize(cache_path:, subdirectory_delimiter: nil, **options)
2929
super(options)
3030
@cache_path = cache_path
31+
@subdirectory_delimiter = subdirectory_delimiter
3132
FileUtils.mkdir_p(@cache_path)
3233
end
3334

3435
# Clear all cache entries
3536
def clear(options = nil)
3637
if File.directory?(@cache_path)
37-
Dir.glob(File.join(@cache_path, "*")).each do |file|
38-
File.delete(file) if File.file?(file)
38+
Dir.glob(File.join(@cache_path, "*")).each do |path|
39+
if File.file?(path)
40+
File.delete(path)
41+
elsif File.directory?(path)
42+
FileUtils.rm_rf(path)
43+
end
3944
end
4045
end
4146
true
@@ -49,8 +54,7 @@ def clear(options = nil)
4954
# @param options [Hash] Options (unused)
5055
# @return [Object, nil] The cached value or nil if not found
5156
def read_entry(key, **options)
52-
hash = hash_key(key)
53-
value_file = value_path(hash)
57+
value_file = value_path_for_key(key)
5458

5559
return nil unless File.exist?(value_file)
5660

@@ -74,6 +78,23 @@ def read_entry(key, **options)
7478
# @param options [Hash] Options (expiration is ignored)
7579
# @return [Boolean] Returns true on success, false on failure
7680
def write_entry(key, entry, **options)
81+
if @subdirectory_delimiter
82+
write_entry_with_subdirectories(key, entry, **options)
83+
else
84+
write_entry_simple(key, entry, **options)
85+
end
86+
rescue StandardError
87+
# Return false if write fails (permissions, disk space, etc.)
88+
false
89+
end
90+
91+
# Write entry using simple hash-based file structure
92+
#
93+
# @param key [String] The cache key
94+
# @param entry [ActiveSupport::Cache::Entry] The cache entry
95+
# @param options [Hash] Options (expiration is ignored)
96+
# @return [Boolean] Returns true on success
97+
def write_entry_simple(key, entry, **options)
7798
hash = hash_key(key)
7899

79100
# Write the key file
@@ -83,9 +104,32 @@ def write_entry(key, entry, **options)
83104
File.write(value_path(hash), serialize_entry(entry, **options))
84105

85106
true
86-
rescue StandardError
87-
# Return false if write fails (permissions, disk space, etc.)
88-
false
107+
end
108+
109+
# Write entry using subdirectory structure
110+
#
111+
# @param key [String] The cache key
112+
# @param entry [ActiveSupport::Cache::Entry] The cache entry
113+
# @param options [Hash] Options (expiration is ignored)
114+
# @return [Boolean] Returns true on success
115+
def write_entry_with_subdirectories(key, entry, **options)
116+
chunks = key.to_s.split(@subdirectory_delimiter)
117+
current_dir = @cache_path
118+
119+
# Create subdirectories for each chunk
120+
chunks.each do |chunk|
121+
chunk_hash = hash_chunk(chunk)
122+
current_dir = File.join(current_dir, chunk_hash)
123+
FileUtils.mkdir_p(current_dir)
124+
125+
# Write _key_chunk file
126+
File.write(File.join(current_dir, "_key_chunk"), chunk)
127+
end
128+
129+
# Write the value file in the final directory
130+
File.write(File.join(current_dir, "value"), serialize_entry(entry, **options))
131+
132+
true
89133
end
90134

91135
# Delete an entry from the cache
@@ -94,6 +138,15 @@ def write_entry(key, entry, **options)
94138
# @param options [Hash] Options (unused)
95139
# @return [Boolean] Returns true if any file was deleted
96140
def delete_entry(key, **options)
141+
if @subdirectory_delimiter
142+
delete_entry_with_subdirectories(key, **options)
143+
else
144+
delete_entry_simple(key, **options)
145+
end
146+
end
147+
148+
# Delete entry using simple hash-based file structure
149+
def delete_entry_simple(key, **options)
97150
hash = hash_key(key)
98151
key_file = key_path(hash)
99152
value_file = value_path(hash)
@@ -115,12 +168,42 @@ def delete_entry(key, **options)
115168
deleted
116169
end
117170

171+
# Delete entry using subdirectory structure
172+
#
173+
# @param key [String] The cache key
174+
# @param options [Hash] Options (unused)
175+
# @return [Boolean] Returns true if the entry was deleted
176+
def delete_entry_with_subdirectories(key, **options)
177+
value_file = value_path_for_key(key)
178+
179+
return false unless File.exist?(value_file)
180+
181+
# Delete only the deepest directory containing this specific entry
182+
current_dir = subdirectory_path_for_key(key)
183+
184+
begin
185+
# Delete the final directory (containing _key_chunk and value)
186+
FileUtils.rm_rf(current_dir) if File.exist?(current_dir)
187+
true
188+
rescue StandardError
189+
false
190+
end
191+
end
192+
118193
# Generate a hash for the given key
119194
#
120195
# @param key [String] The cache key
121-
# @return [String] The SHA256 hash of the key
196+
# @return [String] The hash of the key
122197
def hash_key(key)
123-
::Digest::SHA256.hexdigest(key.to_s)
198+
::ActiveSupport::Digest.hexdigest(key.to_s)
199+
end
200+
201+
# Generate a hash for a key chunk
202+
#
203+
# @param chunk [String] A chunk of the cache key
204+
# @return [String] The hash of the chunk
205+
def hash_chunk(chunk)
206+
::ActiveSupport::Digest.hexdigest(chunk.to_s)
124207
end
125208

126209
# Get the path for the key file
@@ -138,6 +221,34 @@ def key_path(hash)
138221
def value_path(hash)
139222
File.join(@cache_path, "#{hash}.value")
140223
end
224+
225+
# Get the value file path for a given key
226+
#
227+
# @param key [String] The cache key
228+
# @return [String] The full path to the value file
229+
def value_path_for_key(key)
230+
if @subdirectory_delimiter
231+
File.join(subdirectory_path_for_key(key), "value")
232+
else
233+
value_path(hash_key(key))
234+
end
235+
end
236+
237+
# Get the subdirectory path for a given key
238+
#
239+
# @param key [String] The cache key
240+
# @return [String] The full path to the subdirectory for this key
241+
def subdirectory_path_for_key(key)
242+
chunks = key.to_s.split(@subdirectory_delimiter)
243+
current_dir = @cache_path
244+
245+
chunks.each do |chunk|
246+
chunk_hash = hash_chunk(chunk)
247+
current_dir = File.join(current_dir, chunk_hash)
248+
end
249+
250+
current_dir
251+
end
141252
end
142253
end
143254
end

0 commit comments

Comments
 (0)