Skip to content

Commit 8e78912

Browse files
Copilotnhorton
andcommitted
Implement SourceControlCacheStore gem with full functionality
Co-authored-by: nhorton <[email protected]>
1 parent 9f59c32 commit 8e78912

File tree

11 files changed

+565
-2
lines changed

11 files changed

+565
-2
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
/test/tmp/
1010
/test/version_tmp/
1111
/tmp/
12+
.rspec_status
1213

1314
# Used by dotenv library to load environment variables.
1415
# .env
@@ -41,6 +42,7 @@ build-iPhoneSimulator/
4142
## Environment normalization:
4243
/.bundle/
4344
/vendor/bundle
45+
/vendor/
4446
/lib/bundler/man/
4547

4648
# for a library or gem, you might want to ignore these files since the code is

.rspec

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
--require spec_helper
2+
--format documentation
3+
--color

Gemfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gemspec

Gemfile.lock

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
PATH
2+
remote: .
3+
specs:
4+
source_control_cache_store (0.1.0)
5+
activesupport (>= 7.1.0)
6+
7+
GEM
8+
remote: https://rubygems.org/
9+
specs:
10+
activesupport (8.1.1)
11+
base64
12+
bigdecimal
13+
concurrent-ruby (~> 1.0, >= 1.3.1)
14+
connection_pool (>= 2.2.5)
15+
drb
16+
i18n (>= 1.6, < 2)
17+
json
18+
logger (>= 1.4.2)
19+
minitest (>= 5.1)
20+
securerandom (>= 0.3)
21+
tzinfo (~> 2.0, >= 2.0.5)
22+
uri (>= 0.13.1)
23+
base64 (0.3.0)
24+
bigdecimal (3.3.1)
25+
concurrent-ruby (1.3.5)
26+
connection_pool (2.5.4)
27+
diff-lcs (1.6.2)
28+
drb (2.2.3)
29+
i18n (1.14.7)
30+
concurrent-ruby (~> 1.0)
31+
json (2.16.0)
32+
logger (1.7.0)
33+
minitest (5.26.2)
34+
rake (13.3.1)
35+
rspec (3.13.2)
36+
rspec-core (~> 3.13.0)
37+
rspec-expectations (~> 3.13.0)
38+
rspec-mocks (~> 3.13.0)
39+
rspec-core (3.13.6)
40+
rspec-support (~> 3.13.0)
41+
rspec-expectations (3.13.5)
42+
diff-lcs (>= 1.2.0, < 2.0)
43+
rspec-support (~> 3.13.0)
44+
rspec-mocks (3.13.7)
45+
diff-lcs (>= 1.2.0, < 2.0)
46+
rspec-support (~> 3.13.0)
47+
rspec-support (3.13.6)
48+
securerandom (0.4.1)
49+
tzinfo (2.0.6)
50+
concurrent-ruby (~> 1.0)
51+
uri (1.1.1)
52+
53+
PLATFORMS
54+
ruby
55+
x86_64-linux-gnu
56+
57+
DEPENDENCIES
58+
rake (~> 13.0)
59+
rspec (~> 3.0)
60+
source_control_cache_store!
61+
62+
BUNDLED WITH
63+
2.7.2

README.md

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,107 @@
1-
# source_control_cache_store
2-
Rails cache store appropriate for storing the results in source control
1+
# SourceControlCacheStore
2+
3+
Rails cache store appropriate for storing the results in source control.
4+
5+
## Overview
6+
7+
`SourceControlCacheStore` is a Rails cache store (compatible with Rails 7.1 and higher) that stores cache entries as files suitable for version control. Each cache entry is stored as two files:
8+
9+
- `#{hash}.key` - the full key that was used
10+
- `#{hash}.value` - the serialized value that was stored
11+
12+
This cache store is designed to be committed to version control, making it ideal for caching build artifacts, compiled assets, or other deterministic results that should be shared across different environments.
13+
14+
## Features
15+
16+
- **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
18+
- **No expiration**: Cache entries do NOT honor expiration parameters - they persist until explicitly deleted
19+
- **Rails 7.1+ compatible**: Implements the ActiveSupport::Cache::Store interface
20+
21+
## Installation
22+
23+
Add this line to your application's Gemfile:
24+
25+
```ruby
26+
gem 'source_control_cache_store'
27+
```
28+
29+
And then execute:
30+
31+
```bash
32+
$ bundle install
33+
```
34+
35+
## Usage
36+
37+
Configure your Rails application to use the SourceControlCacheStore:
38+
39+
```ruby
40+
# config/application.rb or config/environments/*.rb
41+
config.cache_store = :source_control_cache_store, cache_path: Rails.root.join("tmp", "cache")
42+
```
43+
44+
Or create an instance directly:
45+
46+
```ruby
47+
require 'source_control_cache_store'
48+
49+
cache = ActiveSupport::Cache::SourceControlCacheStore.new(
50+
cache_path: "/path/to/cache/directory"
51+
)
52+
53+
# Write to cache
54+
cache.write("my_key", "my_value")
55+
56+
# Read from cache
57+
value = cache.read("my_key") # => "my_value"
58+
59+
# Fetch with block (returns cached value or executes block and caches result)
60+
result = cache.fetch("computed_key") do
61+
expensive_computation()
62+
end
63+
64+
# Delete a cache entry
65+
cache.delete("my_key")
66+
67+
# Clear all cache entries
68+
cache.clear
69+
```
70+
71+
## Key Features
72+
73+
### Hashed Keys
74+
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:
76+
77+
```ruby
78+
cache.write("user:123:profile", { name: "John" })
79+
# Creates:
80+
# - abc123def456.key (contains "user:123:profile")
81+
# - abc123def456.value (contains serialized hash)
82+
```
83+
84+
### No Expiration
85+
86+
Unlike other cache stores, `SourceControlCacheStore` intentionally ignores expiration parameters:
87+
88+
```ruby
89+
# The expires_in option is ignored
90+
cache.write("key", "value", expires_in: 1.hour)
91+
cache.read("key") # => "value" (will never expire)
92+
```
93+
94+
This behavior is by design, as the cache is intended for version-controlled content that should be explicitly managed rather than automatically expired.
95+
96+
## Development
97+
98+
After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.
99+
100+
## Contributing
101+
102+
Bug reports and pull requests are welcome on GitHub at https://github.com/Unsupervisedcom/source_control_cache_store.
103+
104+
## License
105+
106+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
107+

Rakefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
3+
require "bundler/gem_tasks"
4+
require "rspec/core/rake_task"
5+
6+
RSpec::Core::RakeTask.new(:spec)
7+
8+
task default: :spec
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support"
4+
require "active_support/cache"
5+
require "active_support/notifications"
6+
require "active_support/core_ext/object/json"
7+
require "active_support/core_ext/digest"
8+
require "digest"
9+
require "fileutils"
10+
11+
module ActiveSupport
12+
module Cache
13+
# A cache store implementation that stores cache entries as files
14+
# suitable for version control. Each cache entry is stored as two files:
15+
# - #{hash}.key: the full cache key
16+
# - #{hash}.value: the serialized cache value
17+
#
18+
# This store does NOT honor expiration parameters.
19+
#
20+
# Example usage:
21+
# config.cache_store = :source_control_cache_store, cache_path: "tmp/cache"
22+
class SourceControlCacheStore < Store
23+
attr_reader :cache_path
24+
25+
# Initialize a new SourceControlCacheStore
26+
#
27+
# @param cache_path [String] The directory where cache files will be stored
28+
# @param options [Hash] Additional options (currently unused)
29+
def initialize(cache_path:, **options)
30+
super(options)
31+
@cache_path = cache_path
32+
FileUtils.mkdir_p(@cache_path)
33+
end
34+
35+
# Clear all cache entries
36+
def clear(options = nil)
37+
if File.directory?(@cache_path)
38+
Dir.glob(File.join(@cache_path, "*")).each do |file|
39+
File.delete(file) if File.file?(file)
40+
end
41+
end
42+
true
43+
end
44+
45+
private
46+
47+
# Read an entry from the cache
48+
#
49+
# @param key [String] The cache key
50+
# @param options [Hash] Options (unused)
51+
# @return [Object, nil] The cached value or nil if not found
52+
def read_entry(key, **options)
53+
hash = hash_key(key)
54+
value_file = value_path(hash)
55+
56+
return nil unless File.exist?(value_file)
57+
58+
value = File.read(value_file)
59+
entry = deserialize_entry(value)
60+
61+
# Ignore expiration by creating a new entry without expiration
62+
return entry unless entry.is_a?(ActiveSupport::Cache::Entry)
63+
64+
# Create a new entry that never expires
65+
ActiveSupport::Cache::Entry.new(entry.value, expires_in: nil)
66+
rescue => e
67+
# If we can't read or deserialize, treat as cache miss
68+
nil
69+
end
70+
71+
# Write an entry to the cache
72+
#
73+
# @param key [String] The cache key
74+
# @param entry [ActiveSupport::Cache::Entry] The cache entry
75+
# @param options [Hash] Options (expiration is ignored)
76+
# @return [Boolean] Always returns true
77+
def write_entry(key, entry, **options)
78+
hash = hash_key(key)
79+
80+
# Write the key file
81+
File.write(key_path(hash), key)
82+
83+
# Write the value file
84+
File.write(value_path(hash), serialize_entry(entry, **options))
85+
86+
true
87+
end
88+
89+
# Delete an entry from the cache
90+
#
91+
# @param key [String] The cache key
92+
# @param options [Hash] Options (unused)
93+
# @return [Boolean] Returns true if the entry was deleted
94+
def delete_entry(key, **options)
95+
hash = hash_key(key)
96+
key_file = key_path(hash)
97+
value_file = value_path(hash)
98+
99+
deleted = false
100+
deleted = File.delete(key_file) if File.exist?(key_file)
101+
deleted = File.delete(value_file) if File.exist?(value_file)
102+
103+
deleted
104+
end
105+
106+
# Generate a hash for the given key
107+
#
108+
# @param key [String] The cache key
109+
# @return [String] The SHA256 hash of the key
110+
def hash_key(key)
111+
::Digest::SHA256.hexdigest(key.to_s)
112+
end
113+
114+
# Get the path for the key file
115+
#
116+
# @param hash [String] The hash of the key
117+
# @return [String] The full path to the key file
118+
def key_path(hash)
119+
File.join(@cache_path, "#{hash}.key")
120+
end
121+
122+
# Get the path for the value file
123+
#
124+
# @param hash [String] The hash of the key
125+
# @return [String] The full path to the value file
126+
def value_path(hash)
127+
File.join(@cache_path, "#{hash}.value")
128+
end
129+
end
130+
end
131+
end

lib/source_control_cache_store.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/cache/source_control_cache_store"

source_control_cache_store.gemspec

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
Gem::Specification.new do |spec|
4+
spec.name = "source_control_cache_store"
5+
spec.version = "0.1.0"
6+
spec.authors = ["Unsupervised.com"]
7+
spec.email = ["[email protected]"]
8+
9+
spec.summary = "Rails cache store appropriate for storing the results in source control"
10+
spec.description = "A Rails cache store that stores cache entries as files suitable for version control"
11+
spec.homepage = "https://github.com/Unsupervisedcom/source_control_cache_store"
12+
spec.license = "MIT"
13+
spec.required_ruby_version = ">= 2.7.0"
14+
15+
spec.metadata["homepage_uri"] = spec.homepage
16+
spec.metadata["source_code_uri"] = spec.homepage
17+
18+
spec.files = Dir["lib/**/*", "LICENSE", "README.md"]
19+
spec.require_paths = ["lib"]
20+
21+
spec.add_dependency "activesupport", ">= 7.1.0"
22+
23+
spec.add_development_dependency "rake", "~> 13.0"
24+
spec.add_development_dependency "rspec", "~> 3.0"
25+
end

0 commit comments

Comments
 (0)