diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a2d918a --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index e3200e0..8aea8fb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /test/tmp/ /test/version_tmp/ /tmp/ +.rspec_status # Used by dotenv library to load environment variables. # .env @@ -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 diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..7a2cc1a --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format documentation +--color diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..be173b2 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..1ead83c --- /dev/null +++ b/Gemfile.lock @@ -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 diff --git a/README.md b/README.md index 3c93f7f..04b12d5 100644 --- a/README.md +++ b/README.md @@ -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). + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b6ae734 --- /dev/null +++ b/Rakefile @@ -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 diff --git a/lib/active_support/cache/source_control_cache_store.rb b/lib/active_support/cache/source_control_cache_store.rb new file mode 100644 index 0000000..62421d1 --- /dev/null +++ b/lib/active_support/cache/source_control_cache_store.rb @@ -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 diff --git a/lib/source_control_cache_store.rb b/lib/source_control_cache_store.rb new file mode 100644 index 0000000..be85a4b --- /dev/null +++ b/lib/source_control_cache_store.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "active_support/cache/source_control_cache_store" diff --git a/source_control_cache_store.gemspec b/source_control_cache_store.gemspec new file mode 100644 index 0000000..1d38ec0 --- /dev/null +++ b/source_control_cache_store.gemspec @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = "source_control_cache_store" + spec.version = "0.1.0" + spec.authors = ["Unsupervised.com"] + spec.email = ["noah@unsupervised.com"] + + spec.summary = "Rails cache store appropriate for storing the results in source control" + spec.description = "A Rails cache store that stores cache entries as files suitable for version control" + spec.homepage = "https://github.com/Unsupervisedcom/source_control_cache_store" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.2.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + + spec.files = Dir["lib/**/*", "LICENSE", "README.md"] + spec.require_paths = ["lib"] + + spec.add_dependency "activesupport", ">= 7.1.0" + + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.0" +end diff --git a/spec/source_control_cache_store_spec.rb b/spec/source_control_cache_store_spec.rb new file mode 100644 index 0000000..7b8f1ed --- /dev/null +++ b/spec/source_control_cache_store_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ActiveSupport::Cache::SourceControlCacheStore do + let(:cache_path) { Dir.mktmpdir } + let(:store) { described_class.new(cache_path: cache_path) } + + after do + FileUtils.rm_rf(cache_path) if File.exist?(cache_path) + end + + describe "#initialize" do + it "creates the cache directory if it doesn't exist" do + new_path = File.join(cache_path, "new_cache") + expect(File.directory?(new_path)).to be false + + described_class.new(cache_path: new_path) + + expect(File.directory?(new_path)).to be true + end + + it "stores the cache path" do + expect(store.cache_path).to eq(cache_path) + end + end + + describe "#write and #read" do + it "writes and reads a simple value" do + store.write("test_key", "test_value") + expect(store.read("test_key")).to eq("test_value") + end + + it "writes and reads a complex object" do + complex_object = { name: "John", age: 30, hobbies: ["reading", "coding"] } + store.write("complex", complex_object) + expect(store.read("complex")).to eq(complex_object) + end + + it "returns nil for non-existent keys" do + expect(store.read("non_existent")).to be_nil + end + + it "creates both .key and .value files" do + store.write("my_key", "my_value") + + # Calculate the expected hash + hash = Digest::SHA256.hexdigest("my_key") + key_file = File.join(cache_path, "#{hash}.key") + value_file = File.join(cache_path, "#{hash}.value") + + expect(File.exist?(key_file)).to be true + expect(File.exist?(value_file)).to be true + end + + it "stores the original key in the .key file" do + original_key = "my_special_key" + store.write(original_key, "value") + + hash = Digest::SHA256.hexdigest(original_key) + key_file = File.join(cache_path, "#{hash}.key") + + expect(File.read(key_file)).to eq(original_key) + end + + it "overwrites existing values" do + store.write("key", "value1") + store.write("key", "value2") + expect(store.read("key")).to eq("value2") + end + end + + describe "#delete" do + it "deletes an existing entry" do + store.write("key_to_delete", "value") + expect(store.read("key_to_delete")).to eq("value") + + result = store.delete("key_to_delete") + expect(result).to be_truthy + expect(store.read("key_to_delete")).to be_nil + end + + it "removes both .key and .value files" do + store.write("key", "value") + hash = Digest::SHA256.hexdigest("key") + key_file = File.join(cache_path, "#{hash}.key") + value_file = File.join(cache_path, "#{hash}.value") + + expect(File.exist?(key_file)).to be true + expect(File.exist?(value_file)).to be true + + store.delete("key") + + expect(File.exist?(key_file)).to be false + expect(File.exist?(value_file)).to be false + end + + it "returns false for non-existent keys" do + result = store.delete("non_existent") + expect(result).to be_falsey + end + end + + describe "#clear" do + it "removes all cache files" do + store.write("key1", "value1") + store.write("key2", "value2") + store.write("key3", "value3") + + expect(Dir.glob(File.join(cache_path, "*")).length).to be > 0 + + store.clear + + expect(Dir.glob(File.join(cache_path, "*")).length).to eq(0) + end + + it "returns true" do + expect(store.clear).to be true + end + end + + describe "#fetch" do + it "returns cached value if present" do + store.write("fetch_key", "cached_value") + + result = store.fetch("fetch_key") { "block_value" } + expect(result).to eq("cached_value") + end + + it "executes block and caches result if not present" do + result = store.fetch("new_key") { "computed_value" } + + expect(result).to eq("computed_value") + expect(store.read("new_key")).to eq("computed_value") + end + end + + describe "expiration (should be ignored)" do + it "ignores expires_in option" do + # Write with expiration - it should be ignored + store.write("expiring_key", "value", expires_in: 0.001) + + # Sleep a bit to ensure expiration would have happened + sleep(0.01) + + # Value should still be present since expiration is ignored + expect(store.read("expiring_key")).to eq("value") + end + end + + describe "key hashing" do + it "uses SHA256 for hashing keys" do + key = "test_key" + expected_hash = Digest::SHA256.hexdigest(key) + + store.write(key, "value") + + key_file = File.join(cache_path, "#{expected_hash}.key") + expect(File.exist?(key_file)).to be true + end + + it "handles keys with special characters" do + key = "key/with:special*chars?" + store.write(key, "value") + + expect(store.read(key)).to eq("value") + end + + it "handles very long keys" do + key = "a" * 1000 + store.write(key, "value") + + expect(store.read(key)).to eq("value") + end + end + + describe "edge cases" do + it "handles nil values" do + store.write("nil_key", nil) + expect(store.read("nil_key")).to be_nil + end + + it "handles empty string values" do + store.write("empty_key", "") + expect(store.read("empty_key")).to eq("") + end + + it "handles numeric values" do + store.write("number", 42) + expect(store.read("number")).to eq(42) + end + + it "handles boolean values" do + store.write("true_key", true) + store.write("false_key", false) + + expect(store.read("true_key")).to eq(true) + expect(store.read("false_key")).to eq(false) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..7bd1024 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "source_control_cache_store" +require "tmpdir" +require "fileutils" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end