Skip to content

Commit 31246ae

Browse files
Copilotnhorton
andcommitted
Add subdirectory delimiter feature with tests and documentation
Co-authored-by: nhorton <[email protected]>
1 parent d7d457c commit 31246ae

File tree

3 files changed

+249
-9
lines changed

3 files changed

+249
-9
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,38 @@ 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 SHA256
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

lib/active_support/cache/source_control_cache_store.rb

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,29 @@ module Cache
1919
# Example usage:
2020
# config.cache_store = :source_control_cache_store, cache_path: "tmp/cache"
2121
class SourceControlCacheStore < Store
22-
attr_reader :cache_path
22+
attr_reader :cache_path, :subdirectory_delimiter
2323

2424
# Initialize a new SourceControlCacheStore
2525
#
2626
# @param cache_path [String] The directory where cache files will be stored
27+
# @param subdirectory_delimiter [String, nil] Optional delimiter to split keys into subdirectories
2728
# @param options [Hash] Additional options (currently unused)
28-
def initialize(cache_path:, **options)
29+
def initialize(cache_path:, subdirectory_delimiter: nil, **options)
2930
super(options)
3031
@cache_path = cache_path
32+
@subdirectory_delimiter = subdirectory_delimiter
3133
FileUtils.mkdir_p(@cache_path)
3234
end
3335

3436
# Clear all cache entries
3537
def clear(options = nil)
3638
if File.directory?(@cache_path)
37-
Dir.glob(File.join(@cache_path, "*")).each do |file|
38-
File.delete(file) if File.file?(file)
39+
Dir.glob(File.join(@cache_path, "*")).each do |path|
40+
if File.file?(path)
41+
File.delete(path)
42+
elsif File.directory?(path)
43+
FileUtils.rm_rf(path)
44+
end
3945
end
4046
end
4147
true
@@ -49,8 +55,7 @@ def clear(options = nil)
4955
# @param options [Hash] Options (unused)
5056
# @return [Object, nil] The cached value or nil if not found
5157
def read_entry(key, **options)
52-
hash = hash_key(key)
53-
value_file = value_path(hash)
58+
value_file = value_path_for_key(key)
5459

5560
return nil unless File.exist?(value_file)
5661

@@ -74,6 +79,18 @@ def read_entry(key, **options)
7479
# @param options [Hash] Options (expiration is ignored)
7580
# @return [Boolean] Returns true on success, false on failure
7681
def write_entry(key, entry, **options)
82+
if @subdirectory_delimiter
83+
write_entry_with_subdirectories(key, entry, **options)
84+
else
85+
write_entry_simple(key, entry, **options)
86+
end
87+
rescue StandardError
88+
# Return false if write fails (permissions, disk space, etc.)
89+
false
90+
end
91+
92+
# Write entry using simple hash-based file structure
93+
def write_entry_simple(key, entry, **options)
7794
hash = hash_key(key)
7895

7996
# Write the key file
@@ -83,9 +100,27 @@ def write_entry(key, entry, **options)
83100
File.write(value_path(hash), serialize_entry(entry, **options))
84101

85102
true
86-
rescue StandardError
87-
# Return false if write fails (permissions, disk space, etc.)
88-
false
103+
end
104+
105+
# Write entry using subdirectory structure
106+
def write_entry_with_subdirectories(key, entry, **options)
107+
chunks = key.to_s.split(@subdirectory_delimiter)
108+
current_dir = @cache_path
109+
110+
# Create subdirectories for each chunk
111+
chunks.each_with_index do |chunk, index|
112+
chunk_hash = hash_chunk(chunk)
113+
current_dir = File.join(current_dir, chunk_hash)
114+
FileUtils.mkdir_p(current_dir)
115+
116+
# Write _key_chunk file
117+
File.write(File.join(current_dir, "_key_chunk"), chunk)
118+
end
119+
120+
# Write the value file in the final directory
121+
File.write(File.join(current_dir, "value"), serialize_entry(entry, **options))
122+
123+
true
89124
end
90125

91126
# Delete an entry from the cache
@@ -94,6 +129,15 @@ def write_entry(key, entry, **options)
94129
# @param options [Hash] Options (unused)
95130
# @return [Boolean] Returns true if any file was deleted
96131
def delete_entry(key, **options)
132+
if @subdirectory_delimiter
133+
delete_entry_with_subdirectories(key, **options)
134+
else
135+
delete_entry_simple(key, **options)
136+
end
137+
end
138+
139+
# Delete entry using simple hash-based file structure
140+
def delete_entry_simple(key, **options)
97141
hash = hash_key(key)
98142
key_file = key_path(hash)
99143
value_file = value_path(hash)
@@ -115,6 +159,25 @@ def delete_entry(key, **options)
115159
deleted
116160
end
117161

162+
# Delete entry using subdirectory structure
163+
def delete_entry_with_subdirectories(key, **options)
164+
value_file = value_path_for_key(key)
165+
166+
return false unless File.exist?(value_file)
167+
168+
# Delete the entire directory tree for this key
169+
chunks = key.to_s.split(@subdirectory_delimiter)
170+
first_chunk_hash = hash_chunk(chunks[0])
171+
dir_to_delete = File.join(@cache_path, first_chunk_hash)
172+
173+
begin
174+
FileUtils.rm_rf(dir_to_delete) if File.exist?(dir_to_delete)
175+
true
176+
rescue StandardError
177+
false
178+
end
179+
end
180+
118181
# Generate a hash for the given key
119182
#
120183
# @param key [String] The cache key
@@ -123,6 +186,14 @@ def hash_key(key)
123186
::Digest::SHA256.hexdigest(key.to_s)
124187
end
125188

189+
# Generate a hash for a key chunk
190+
#
191+
# @param chunk [String] A chunk of the cache key
192+
# @return [String] The SHA256 hash of the chunk
193+
def hash_chunk(chunk)
194+
::Digest::SHA256.hexdigest(chunk.to_s)
195+
end
196+
126197
# Get the path for the key file
127198
#
128199
# @param hash [String] The hash of the key
@@ -138,6 +209,26 @@ def key_path(hash)
138209
def value_path(hash)
139210
File.join(@cache_path, "#{hash}.value")
140211
end
212+
213+
# Get the value file path for a given key
214+
#
215+
# @param key [String] The cache key
216+
# @return [String] The full path to the value file
217+
def value_path_for_key(key)
218+
if @subdirectory_delimiter
219+
chunks = key.to_s.split(@subdirectory_delimiter)
220+
current_dir = @cache_path
221+
222+
chunks.each do |chunk|
223+
chunk_hash = hash_chunk(chunk)
224+
current_dir = File.join(current_dir, chunk_hash)
225+
end
226+
227+
File.join(current_dir, "value")
228+
else
229+
value_path(hash_key(key))
230+
end
231+
end
141232
end
142233
end
143234
end

spec/source_control_cache_store_spec.rb

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,121 @@
198198
expect(store.read("false_key")).to eq(false)
199199
end
200200
end
201+
202+
describe "subdirectory_delimiter feature" do
203+
let(:cache_path_with_delimiter) { Dir.mktmpdir }
204+
let(:store_with_delimiter) { described_class.new(cache_path: cache_path_with_delimiter, subdirectory_delimiter: "---") }
205+
206+
after do
207+
FileUtils.rm_rf(cache_path_with_delimiter) if File.exist?(cache_path_with_delimiter)
208+
end
209+
210+
it "stores subdirectory_delimiter parameter" do
211+
expect(store_with_delimiter.subdirectory_delimiter).to eq("---")
212+
end
213+
214+
it "creates nested directories for split keys" do
215+
store_with_delimiter.write("foo---bar---boo-ba", "27")
216+
217+
# Calculate expected hashes
218+
foo_hash = Digest::SHA256.hexdigest("foo")
219+
bar_hash = Digest::SHA256.hexdigest("bar")
220+
boo_ba_hash = Digest::SHA256.hexdigest("boo-ba")
221+
222+
# Check that directories exist
223+
expect(File.directory?(File.join(cache_path_with_delimiter, foo_hash))).to be true
224+
expect(File.directory?(File.join(cache_path_with_delimiter, foo_hash, bar_hash))).to be true
225+
expect(File.directory?(File.join(cache_path_with_delimiter, foo_hash, bar_hash, boo_ba_hash))).to be true
226+
end
227+
228+
it "creates _key_chunk files with correct content" do
229+
store_with_delimiter.write("foo---bar---boo-ba", "27")
230+
231+
foo_hash = Digest::SHA256.hexdigest("foo")
232+
bar_hash = Digest::SHA256.hexdigest("bar")
233+
boo_ba_hash = Digest::SHA256.hexdigest("boo-ba")
234+
235+
# Check _key_chunk files
236+
foo_chunk_file = File.join(cache_path_with_delimiter, foo_hash, "_key_chunk")
237+
bar_chunk_file = File.join(cache_path_with_delimiter, foo_hash, bar_hash, "_key_chunk")
238+
boo_ba_chunk_file = File.join(cache_path_with_delimiter, foo_hash, bar_hash, boo_ba_hash, "_key_chunk")
239+
240+
expect(File.read(foo_chunk_file)).to eq("foo")
241+
expect(File.read(bar_chunk_file)).to eq("bar")
242+
expect(File.read(boo_ba_chunk_file)).to eq("boo-ba")
243+
end
244+
245+
it "stores value in the final directory" do
246+
store_with_delimiter.write("foo---bar---boo-ba", "27")
247+
248+
foo_hash = Digest::SHA256.hexdigest("foo")
249+
bar_hash = Digest::SHA256.hexdigest("bar")
250+
boo_ba_hash = Digest::SHA256.hexdigest("boo-ba")
251+
252+
value_file = File.join(cache_path_with_delimiter, foo_hash, bar_hash, boo_ba_hash, "value")
253+
254+
expect(File.exist?(value_file)).to be true
255+
expect(store_with_delimiter.read("foo---bar---boo-ba")).to eq("27")
256+
end
257+
258+
it "reads values correctly from subdirectory structure" do
259+
store_with_delimiter.write("alpha---beta", "test_value")
260+
expect(store_with_delimiter.read("alpha---beta")).to eq("test_value")
261+
end
262+
263+
it "handles single chunk keys (no delimiter present)" do
264+
store_with_delimiter.write("single_key", "single_value")
265+
266+
single_hash = Digest::SHA256.hexdigest("single_key")
267+
value_file = File.join(cache_path_with_delimiter, single_hash, "value")
268+
269+
expect(File.exist?(value_file)).to be true
270+
expect(store_with_delimiter.read("single_key")).to eq("single_value")
271+
end
272+
273+
it "deletes entries in subdirectory structure" do
274+
store_with_delimiter.write("foo---bar---baz", "value")
275+
expect(store_with_delimiter.read("foo---bar---baz")).to eq("value")
276+
277+
result = store_with_delimiter.delete("foo---bar---baz")
278+
expect(result).to be_truthy
279+
expect(store_with_delimiter.read("foo---bar---baz")).to be_nil
280+
end
281+
282+
it "clears all entries including subdirectories" do
283+
store_with_delimiter.write("key1---sub1", "value1")
284+
store_with_delimiter.write("key2---sub2", "value2")
285+
store_with_delimiter.write("key3---sub3---sub4", "value3")
286+
287+
expect(Dir.glob(File.join(cache_path_with_delimiter, "*")).length).to be > 0
288+
289+
store_with_delimiter.clear
290+
291+
expect(Dir.glob(File.join(cache_path_with_delimiter, "*")).length).to eq(0)
292+
end
293+
294+
it "uses fetch correctly with subdirectory structure" do
295+
result = store_with_delimiter.fetch("new---key") { "computed" }
296+
expect(result).to eq("computed")
297+
expect(store_with_delimiter.read("new---key")).to eq("computed")
298+
end
299+
300+
it "overwrites existing values in subdirectory structure" do
301+
store_with_delimiter.write("key---sub", "value1")
302+
store_with_delimiter.write("key---sub", "value2")
303+
expect(store_with_delimiter.read("key---sub")).to eq("value2")
304+
end
305+
306+
it "handles complex objects in subdirectory structure" do
307+
complex_object = { name: "Test", data: [1, 2, 3] }
308+
store_with_delimiter.write("obj---data", complex_object)
309+
expect(store_with_delimiter.read("obj---data")).to eq(complex_object)
310+
end
311+
312+
it "handles many levels of nesting" do
313+
key = "a---b---c---d---e---f"
314+
store_with_delimiter.write(key, "deep_value")
315+
expect(store_with_delimiter.read(key)).to eq("deep_value")
316+
end
317+
end
201318
end

0 commit comments

Comments
 (0)