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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
ruby-version: ['3.2', '3.3']

steps:
- uses: actions/checkout@v4

- name: Set up Ruby ${{ matrix.ruby-version }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true

- name: Install dependencies
run: bundle install

- name: Run tests
run: bundle exec rspec
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/test/tmp/
/test/version_tmp/
/tmp/
.rspec_status

# Used by dotenv library to load environment variables.
# .env
Expand Down Expand Up @@ -41,6 +42,7 @@ build-iPhoneSimulator/
## Environment normalization:
/.bundle/
/vendor/bundle
/vendor/
/lib/bundler/man/

# for a library or gem, you might want to ignore these files since the code is
Expand Down
3 changes: 3 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
--require spec_helper
--format documentation
--color
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

source "https://rubygems.org"

gemspec
63 changes: 63 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
PATH
remote: .
specs:
source_control_cache_store (0.1.0)
activesupport (>= 7.1.0)

GEM
remote: https://rubygems.org/
specs:
activesupport (8.1.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
base64 (0.3.0)
bigdecimal (3.3.1)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
diff-lcs (1.6.2)
drb (2.2.3)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
json (2.16.0)
logger (1.7.0)
minitest (5.26.2)
rake (13.3.1)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.6)
securerandom (0.4.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uri (1.1.1)

PLATFORMS
ruby
x86_64-linux-gnu

DEPENDENCIES
rake (~> 13.0)
rspec (~> 3.0)
source_control_cache_store!

BUNDLED WITH
2.7.2
109 changes: 107 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,107 @@
# source_control_cache_store
Rails cache store appropriate for storing the results in source control
# SourceControlCacheStore

Rails cache store appropriate for storing the results in source control.

## Overview

`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:

- `#{hash}.key` - the full key that was used
- `#{hash}.value` - the serialized value that was stored

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.

## 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
- **No expiration**: Cache entries do NOT honor expiration parameters - they persist until explicitly deleted
- **Rails 7.1+ compatible**: Implements the ActiveSupport::Cache::Store interface

## Installation

Add this line to your application's Gemfile:

```ruby
gem 'source_control_cache_store'
```

And then execute:

```bash
$ bundle install
```

## Usage

Configure your Rails application to use the SourceControlCacheStore:

```ruby
# config/application.rb or config/environments/*.rb
config.cache_store = :source_control_cache_store, cache_path: Rails.root.join("tmp", "cache")
```

Or create an instance directly:

```ruby
require 'source_control_cache_store'

cache = ActiveSupport::Cache::SourceControlCacheStore.new(
cache_path: "/path/to/cache/directory"
)

# Write to cache
cache.write("my_key", "my_value")

# Read from cache
value = cache.read("my_key") # => "my_value"

# Fetch with block (returns cached value or executes block and caches result)
result = cache.fetch("computed_key") do
expensive_computation()
end

# Delete a cache entry
cache.delete("my_key")

# Clear all cache entries
cache.clear
```

## 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:

```ruby
cache.write("user:123:profile", { name: "John" })
# Creates:
# - abc123def456.key (contains "user:123:profile")
# - abc123def456.value (contains serialized hash)
```

### No Expiration

Unlike other cache stores, `SourceControlCacheStore` intentionally ignores expiration parameters:

```ruby
# The expires_in option is ignored
cache.write("key", "value", expires_in: 1.hour)
cache.read("key") # => "value" (will never expire)
```

This behavior is by design, as the cache is intended for version-controlled content that should be explicitly managed rather than automatically expired.

## Development

After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.

## Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/Unsupervisedcom/source_control_cache_store.

## License

The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).

8 changes: 8 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

require "bundler/gem_tasks"
require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec)

task default: :spec
143 changes: 143 additions & 0 deletions lib/active_support/cache/source_control_cache_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# frozen_string_literal: true

require "active_support"
require "active_support/cache"
require "active_support/notifications"
require "active_support/core_ext/object/json"
require "digest"
require "fileutils"

module ActiveSupport
module Cache
# A cache store implementation that stores cache entries as files
# suitable for version control. Each cache entry is stored as two files:
# - #{hash}.key: the full cache key
# - #{hash}.value: the serialized cache value
#
# This store does NOT honor expiration parameters.
#
# Example usage:
# config.cache_store = :source_control_cache_store, cache_path: "tmp/cache"
class SourceControlCacheStore < Store
attr_reader :cache_path

# Initialize a new SourceControlCacheStore
#
# @param cache_path [String] The directory where cache files will be stored
# @param options [Hash] Additional options (currently unused)
def initialize(cache_path:, **options)
super(options)
@cache_path = cache_path
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)
end
end
true
end

private

# Read an entry from the cache
#
# @param key [String] The cache key
# @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)

return nil unless File.exist?(value_file)

value = File.read(value_file)
entry = deserialize_entry(value)

# Ignore expiration by creating a new entry without expiration
return entry unless entry.is_a?(ActiveSupport::Cache::Entry)

# Create a new entry that never expires
ActiveSupport::Cache::Entry.new(entry.value, expires_in: nil)
rescue StandardError
# If we can't read or deserialize, treat as cache miss
nil
end

# Write an entry to the cache
#
# @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, false on failure
def write_entry(key, entry, **options)
hash = hash_key(key)

# Write the key file
File.write(key_path(hash), key)

# Write the value file
File.write(value_path(hash), serialize_entry(entry, **options))

true
rescue StandardError
# Return false if write fails (permissions, disk space, etc.)
false
end

# Delete an entry from the cache
#
# @param key [String] The cache key
# @param options [Hash] Options (unused)
# @return [Boolean] Returns true if any file was deleted
def delete_entry(key, **options)
hash = hash_key(key)
key_file = key_path(hash)
value_file = value_path(hash)

deleted = false

begin
deleted = true if File.exist?(key_file) && File.delete(key_file)
rescue StandardError
# Ignore errors, continue trying to delete value file
end

begin
deleted = true if File.exist?(value_file) && File.delete(value_file)
rescue StandardError
# Ignore errors
end

deleted
end

# Generate a hash for the given key
#
# @param key [String] The cache key
# @return [String] The SHA256 hash of the key
def hash_key(key)
::Digest::SHA256.hexdigest(key.to_s)
end

# Get the path for the key file
#
# @param hash [String] The hash of the key
# @return [String] The full path to the key file
def key_path(hash)
File.join(@cache_path, "#{hash}.key")
end

# Get the path for the value file
#
# @param hash [String] The hash of the key
# @return [String] The full path to the value file
def value_path(hash)
File.join(@cache_path, "#{hash}.value")
end
end
end
end
3 changes: 3 additions & 0 deletions lib/source_control_cache_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true

require "active_support/cache/source_control_cache_store"
Loading