Skip to content

Commit eb12d0a

Browse files
authored
Merge pull request #1 from Unsupervisedcom/copilot/add-rails-cache-store
Implement SourceControlCacheStore: file-based Rails cache for version control
2 parents 6799d94 + 71930ca commit eb12d0a

File tree

12 files changed

+608
-2
lines changed

12 files changed

+608
-2
lines changed

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
strategy:
15+
matrix:
16+
ruby-version: ['3.2', '3.3']
17+
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- name: Set up Ruby ${{ matrix.ruby-version }}
22+
uses: ruby/setup-ruby@v1
23+
with:
24+
ruby-version: ${{ matrix.ruby-version }}
25+
bundler-cache: true
26+
27+
- name: Install dependencies
28+
run: bundle install
29+
30+
- name: Run tests
31+
run: bundle exec rspec

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

0 commit comments

Comments
 (0)