From 8b4496ce64408f711bb0bc34c1aab0e841d6f02d Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sun, 4 Jan 2026 13:39:09 -0800 Subject: [PATCH 01/10] Improve fiber state management in blocks --- .github/workflows/continuous_integration.yml | 2 +- AGENTS.md | 74 ++++++ CHANGELOG.md | 13 + Rakefile | 17 -- VERSION | 2 +- lib/support_table_cache.rb | 56 ++--- lib/support_table_cache/fiber_locals.rb | 47 ++++ spec/support_table_cache/fiber_locals_spec.rb | 222 ++++++++++++++++++ 8 files changed, 386 insertions(+), 47 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/support_table_cache/fiber_locals.rb create mode 100644 spec/support_table_cache/fiber_locals_spec.rb diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index ebe9388..9c793bb 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -63,4 +63,4 @@ jobs: run: bundle exec rake standard - name: yard if: matrix.yard == true - run: bundle exec yard --fail-on-warning + run: bundle exec yard doc --fail-on-warning diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3eb7d0e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# Copilot Instructions for support_table_cache + +## Project Overview + +This is a Ruby gem that adds transparent caching to ActiveRecord support/lookup tables. It intercepts `find_by` queries and `belongs_to` associations to cache small, rarely-changing reference tables (statuses, types, categories) without code changes. + +**Core principle**: Cache entries keyed by unique attribute combinations, auto-invalidated on record changes via `after_commit` callbacks. + +## Architecture + +- **lib/support_table_cache.rb**: Main module with `cache_by` DSL, cache configuration, and invalidation logic +- **lib/support_table_cache/find_by_override.rb**: Prepends to model class to intercept `find_by` calls +- **lib/support_table_cache/relation_override.rb**: Prepends to `ActiveRecord::Relation` to handle scoped queries (e.g., `where(group: 'x').find_by(name: 'y')`) +- **lib/support_table_cache/associations.rb**: Extends `belongs_to` with `cache_belongs_to` to cache foreign key lookups +- **lib/support_table_cache/memory_cache.rb**: In-process cache implementation (use `support_table_cache = :memory`) + +See [ARCHITECTURE.md](../ARCHITECTURE.md) for detailed flow diagrams showing cache key generation, invalidation, and association caching sequences. + +## Key Patterns + +### Model Configuration +Models use `cache_by` to declare unique keys that can be cached. Support composite keys and case-insensitivity: +```ruby +cache_by :name, case_sensitive: false +cache_by [:group, :code] +cache_by :name, where: {deleted_at: nil} # For default scopes +``` + +### Cache Key Structure +Cache keys are `[ClassName, {attr1: val1, attr2: val2}]` arrays with sorted attribute names. Case-insensitive values are downcased before keying. + +### Module Prepending Pattern +Uses `prepend` to wrap ActiveRecord methods (`find_by`) rather than monkey-patching. This allows `super` to call original behavior on cache misses or when caching disabled. + +## Testing + +- **Multi-version testing**: Uses Appraisal gem to test against ActiveRecord 5.0-8.0 (see [Appraisals](../Appraisals)) +- **Run tests**: `bundle exec rspec` (default rake task) or `bundle exec appraisal rspec` for all versions +- **Test setup**: In-memory SQLite database created in [spec/spec_helper.rb](../spec/spec_helper.rb) with test tables +- **Test isolation**: Tests wrapped with `SupportTableCache.testing!` in RSpec `config.before` to prevent cache pollution + +### Code Style +Use **standardrb** for linting. Run `standardrb --fix` before committing. CI enforces this on ActiveRecord 8.0 matrix entry. + +## Common Operations + +### Adding Cache Support to Models +1. Include `SupportTableCache` in model class +2. Call `cache_by` with unique key attributes +3. Optionally set `self.support_table_cache_ttl = 5.minutes` +4. For associations: include `SupportTableCache::Associations` in parent model, then `cache_belongs_to :association_name` + +### Cache Invalidation +Automatic via `after_commit` callback that clears all cache key variations (both old and new attribute values on updates). No manual invalidation needed unless using in-memory cache across processes. + +### Debugging Cache Behavior +- Use `fetch_by` instead of `find_by` to raise error if query won't hit cache +- Disable caching in block: `Model.disable_cache { ... }` or globally `SupportTableCache.disable { ... }` +- Check if caching enabled: inspect `support_table_cache_by_attributes` class attribute + +## Development Workflow + +1. **Running specs locally**: `bundle exec rspec` (uses Ruby 3.3+ and ActiveRecord 8.0 from Gemfile) +2. **Testing specific AR version**: `bundle exec appraisal activerecord_7 rspec` +3. **Generating all gemfiles**: `bundle exec appraisal generate` +4. **Lint before commit**: `standardrb --fix` +5. **Release**: Only from `main` branch (enforced by `Rakefile` pre-release check) + +## Important Constraints + +- **Target models**: Only for small tables (few hundred rows max) +- **Unique keys only**: `cache_by` attributes must define unique constraints +- **No runtime scopes**: Cannot use `cache_belongs_to` with scoped associations (checked at configuration time) +- **In-memory cache caveat**: Per-process, not invalidated across processes—only use for truly static data or with TTL diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cb874..d1a5ca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.2.0 + +### Changed + +- Replaced thread local variables with fiber local variables to prevent behavior from leaking across fibers. + ## 1.1.4 ### Fixed @@ -13,21 +19,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 1.1.3 ### Fixed + - Avoid calling methods that require a database connection when setting up belongs to caching. ## 1.1.2 ### Fixed + - Do not cache records where only some of the columns have been loaded with a call to `select`. ## 1.1.1 ### Fixed + - Fixed disabled and disable_cache methods to yield a block to match the documentation. ## 1.1.0 ### Added + - Added fetch_by and fetch_by! methods that can verify the result will be cacheable. - Allow configuring cache storage on a per class basis. - Allow disabling caching on per class basis. @@ -36,15 +46,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added test mode to intialize new caches within a test block. ### Changed + - Changed fiber local variables used for disabling the cache to thread local variables. - Using find_by! on a relation will now use the cache. ## 1.0.1 ### Added + - Preserve scope on relations terminated with a `find_by`. ## 1.0.0 ### Added + - Add SupportTableCache concern to enable automatic caching on models when calling `find_by` with unique key parameters. diff --git a/Rakefile b/Rakefile index 584050e..3deea70 100644 --- a/Rakefile +++ b/Rakefile @@ -4,9 +4,6 @@ rescue LoadError puts "You must `gem install bundler` and `bundle install` to run rake tasks" end -require "yard" -YARD::Rake::YardocTask.new(:yard) - require "bundler/gem_tasks" task :verify_release_branch do @@ -23,17 +20,3 @@ require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) task default: :spec - -desc "run the specs using appraisal" -task :appraisals do - exec "bundle exec appraisal rake spec" -end - -namespace :appraisals do - desc "install all the appraisal gemspecs" - task :install do - exec "bundle exec appraisal install" - end -end - -require "standard/rake" diff --git a/VERSION b/VERSION index 65087b4..26aaba0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.4 +1.2.0 diff --git a/lib/support_table_cache.rb b/lib/support_table_cache.rb index 5e4a065..8abeb55 100644 --- a/lib/support_table_cache.rb +++ b/lib/support_table_cache.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "support_table_cache/associations" +require_relative "support_table_cache/fiber_locals" require_relative "support_table_cache/find_by_override" require_relative "support_table_cache/relation_override" require_relative "support_table_cache/memory_cache" @@ -10,6 +11,13 @@ module SupportTableCache extend ActiveSupport::Concern + NOT_SET = Object.new.freeze + private_constant :NOT_SET + + @fiber_locals = FiberLocals.new + @cache = NOT_SET + @disabled = false + included do # @api private Used to store the list of attribute names used for caching. class_attribute :support_table_cache_by_attributes, instance_accessor: false @@ -42,14 +50,7 @@ module ClassMethods # @yield Executes the provided block with caching disabled or enabled. # @return [Object] The return value of the block. def disable_cache(disabled = true, &block) - varname = "support_table_cache_disabled:#{name}" - save_val = Thread.current.thread_variable_get(varname) - begin - Thread.current.thread_variable_set(varname, !!disabled) - yield - ensure - Thread.current.thread_variable_set(varname, save_val) - end + SupportTableCache.with_fiber_local("support_table_cache_disabled:#{name}", !!disabled, &block) end # Enable the caching behavior for this class within the block. The enabled setting @@ -127,7 +128,7 @@ def cache_by(attributes, case_sensitive: true, where: nil) private def support_table_cache_disabled? - current_block_value = Thread.current.thread_variable_get("support_table_cache_disabled:#{name}") + current_block_value = SupportTableCache.fiber_local_value("support_table_cache_disabled:#{name}") if current_block_value.nil? SupportTableCache.disabled? else @@ -150,13 +151,7 @@ class << self # @return [Object, nil] The return value of the block if a block is given, nil otherwise. def disable(disabled = true, &block) if block - save_val = Thread.current.thread_variable_get(:support_table_cache_disabled) - begin - Thread.current.thread_variable_set(:support_table_cache_disabled, !!disabled) - yield - ensure - Thread.current.thread_variable_set(:support_table_cache_disabled, save_val) - end + SupportTableCache.with_fiber_local("support_table_cache_disabled", !!disabled, &block) else @disabled = !!disabled end @@ -174,9 +169,9 @@ def enable(&block) # Return true if caching has been disabled. # @return [Boolean] def disabled? - block_value = Thread.current.thread_variable_get(:support_table_cache_disabled) + block_value = SupportTableCache.fiber_local_value("support_table_cache_disabled") if block_value.nil? - !!(defined?(@disabled) && @disabled) + !!@disabled else block_value end @@ -197,7 +192,7 @@ def cache=(value) def cache if testing_cache testing_cache - elsif defined?(@cache) + elsif @cache != NOT_SET @cache elsif defined?(Rails.cache) Rails.cache @@ -211,14 +206,11 @@ def cache # @yield Executes the provided block in test mode. # @return [Object] The return value of the block. def testing!(&block) - save_val = Thread.current.thread_variable_get(:support_table_cache_test_cache) + save_val = SupportTableCache.fiber_local_value("support_table_cache_test_cache") if save_val.nil? - Thread.current.thread_variable_set(:support_table_cache_test_cache, MemoryCache.new) - end - begin + SupportTableCache.with_fiber_local("support_table_cache_test_cache", MemoryCache.new, &block) + else yield - ensure - Thread.current.thread_variable_set(:support_table_cache_test_cache, save_val) end end @@ -227,9 +219,9 @@ def testing!(&block) # @return [SupportTableCache::MemoryCache, nil] The test cache or nil if not in test mode. # @api private def testing_cache - unless defined?(@cache) && @cache.nil? - Thread.current.thread_variable_get(:support_table_cache_test_cache) - end + return nil if @cache.nil? + + SupportTableCache.fiber_local_value("support_table_cache_test_cache") end # Generate a consistent cache key for a set of attributes. It will return nil if the attributes @@ -258,6 +250,14 @@ def cache_key(klass, attributes, key_attribute_names, case_sensitive) [klass.name, sorted_attributes] end + + def fiber_local_value(varname) + @fiber_locals[varname] + end + + def with_fiber_local(varname, value, &block) + @fiber_locals.with(varname, value, &block) + end end # Remove the cache entry for this record. diff --git a/lib/support_table_cache/fiber_locals.rb b/lib/support_table_cache/fiber_locals.rb new file mode 100644 index 0000000..9ca4074 --- /dev/null +++ b/lib/support_table_cache/fiber_locals.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module SupportTableCache + # Utility class for managing fiber-local variables. This implementation + # does not pollute the global namespace. + class FiberLocals + def initialize + @mutex = Mutex.new + @locals = {} + end + + def [](key) + @locals[Fiber.current.object_id]&.fetch(key, nil) + end + + def with(key, value) + fiber_id = Fiber.current.object_id + fiber_locals = nil + previous_value = nil + inited_vars = false + + begin + @mutex.synchronize do + fiber_locals = @locals[fiber_id] + if fiber_locals.nil? + fiber_locals = {} + @locals[fiber_id] = fiber_locals + inited_vars = true + end + end + + previous_value = fiber_locals[key] + fiber_locals[key] = value + + yield + ensure + if inited_vars + @mutex.synchronize do + @locals.delete(fiber_id) + end + else + fiber_locals[key] = previous_value + end + end + end + end +end diff --git a/spec/support_table_cache/fiber_locals_spec.rb b/spec/support_table_cache/fiber_locals_spec.rb new file mode 100644 index 0000000..05bd6ba --- /dev/null +++ b/spec/support_table_cache/fiber_locals_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe SupportTableCache::FiberLocals do + let(:fiber_locals) { described_class.new } + + describe "#[]" do + it "returns nil for unset keys" do + expect(fiber_locals[:foo]).to be_nil + end + + it "returns nil for keys from other fibers" do + fiber_locals.with(:foo, :bar) do + # Set in this fiber + end + + # Access from main fiber should return nil + expect(fiber_locals[:foo]).to be_nil + end + + it "is fiber-isolated" do + fiber_locals.with(:key, :main_value) do + result = nil + fiber = Fiber.new do + fiber_locals.with(:key, :fiber_value) do + result = fiber_locals[:key] + end + end + fiber.resume + + expect(result).to eq(:fiber_value) + expect(fiber_locals[:key]).to eq(:main_value) + end + end + end + + describe "#with" do + it "sets a value for the duration of the block" do + expect(fiber_locals[:key]).to be_nil + + fiber_locals.with(:key, :value) do + expect(fiber_locals[:key]).to eq(:value) + end + + expect(fiber_locals[:key]).to be_nil + end + + it "returns the block's return value" do + result = fiber_locals.with(:key, :value) do + "returned value" + end + + expect(result).to eq("returned value") + end + + it "restores previous value after block" do + fiber_locals.with(:key, :first) do + expect(fiber_locals[:key]).to eq(:first) + + fiber_locals.with(:key, :second) do + expect(fiber_locals[:key]).to eq(:second) + end + + expect(fiber_locals[:key]).to eq(:first) + end + + expect(fiber_locals[:key]).to be_nil + end + + it "supports nested with blocks for different keys" do + fiber_locals.with(:key1, :value1) do + expect(fiber_locals[:key1]).to eq(:value1) + expect(fiber_locals[:key2]).to be_nil + + fiber_locals.with(:key2, :value2) do + expect(fiber_locals[:key1]).to eq(:value1) + expect(fiber_locals[:key2]).to eq(:value2) + end + + expect(fiber_locals[:key1]).to eq(:value1) + expect(fiber_locals[:key2]).to be_nil + end + end + + it "restores previous value even on exception" do + expect { + fiber_locals.with(:key, :value) do + raise "boom" + end + }.to raise_error("boom") + + expect(fiber_locals[:key]).to be_nil + end + + it "cleans up fiber locals when first initialized in a fiber" do + fiber = Fiber.new do + fiber_locals.with(:key, :value) do + expect(fiber_locals[:key]).to eq(:value) + end + # After the block, locals should be cleaned up + expect(fiber_locals[:key]).to be_nil + end + + fiber.resume + end + + it "is thread-safe" do + threads = 10.times.map do |i| + Thread.new do + fiber_locals.with(:thread_key, "thread_#{i}") do + sleep(0.001) + expect(fiber_locals[:thread_key]).to eq("thread_#{i}") + end + end + end + + threads.each(&:join) + end + + it "handles multiple fibers in the same thread" do + results = [] + + fiber1 = Fiber.new do + fiber_locals.with(:key, :fiber1_value) do + results << fiber_locals[:key] + Fiber.yield + results << fiber_locals[:key] + end + end + + fiber2 = Fiber.new do + fiber_locals.with(:key, :fiber2_value) do + results << fiber_locals[:key] + Fiber.yield + results << fiber_locals[:key] + end + end + + fiber1.resume + fiber2.resume + fiber1.resume + fiber2.resume + + expect(results).to eq([:fiber1_value, :fiber2_value, :fiber1_value, :fiber2_value]) + end + + it "handles nil values" do + fiber_locals.with(:key, :initial) do + expect(fiber_locals[:key]).to eq(:initial) + + fiber_locals.with(:key, nil) do + expect(fiber_locals[:key]).to be_nil + end + + expect(fiber_locals[:key]).to eq(:initial) + end + end + + it "handles false values" do + fiber_locals.with(:key, false) do + expect(fiber_locals[:key]).to eq(false) + end + + expect(fiber_locals[:key]).to be_nil + end + + it "supports various key types" do + fiber_locals.with(:symbol_key, :value1) do + expect(fiber_locals[:symbol_key]).to eq(:value1) + end + + fiber_locals.with("string_key", :value2) do + expect(fiber_locals["string_key"]).to eq(:value2) + end + + fiber_locals.with(123, :value3) do + expect(fiber_locals[123]).to eq(:value3) + end + end + + it "does not leak memory across fibers" do + initial_locals_count = fiber_locals.instance_variable_get(:@locals).size + + 100.times do + Fiber.new do + fiber_locals.with(:key, :value) do + # Use the value + end + end.resume + end + + # Locals should be cleaned up for completed fibers + final_locals_count = fiber_locals.instance_variable_get(:@locals).size + expect(final_locals_count).to be <= initial_locals_count + 1 + end + end + + describe "fiber isolation" do + it "maintains separate values per fiber" do + main_result = nil + fiber_result = nil + + fiber_locals.with(:shared_key, :main_value) do + main_result = fiber_locals[:shared_key] + + fiber = Fiber.new do + fiber_locals.with(:shared_key, :fiber_value) do + fiber_result = fiber_locals[:shared_key] + end + end + fiber.resume + + # Main fiber value should be unchanged + expect(fiber_locals[:shared_key]).to eq(:main_value) + end + + expect(main_result).to eq(:main_value) + expect(fiber_result).to eq(:fiber_value) + end + end +end From f37dba258820cba08d8057bfc08c6b4692249f01 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sun, 4 Jan 2026 15:46:45 -0800 Subject: [PATCH 02/10] fix ci task --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 9c793bb..aa096fa 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -60,7 +60,7 @@ jobs: run: bundle exec rake - name: standardrb if: matrix.standardrb == true - run: bundle exec rake standard + run: bundle exec standardrb - name: yard if: matrix.yard == true run: bundle exec yard doc --fail-on-warning From 7bce53195a5e80634a62379e87857e724fbd2cdc Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Sun, 4 Jan 2026 15:50:29 -0800 Subject: [PATCH 03/10] Change version --- CHANGELOG.md | 6 +++--- VERSION | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a5ca1..a253c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 1.2.0 +## 1.1.5 -### Changed +### Fixed -- Replaced thread local variables with fiber local variables to prevent behavior from leaking across fibers. +- Replaced thread local variables with fiber local variables to prevent the possibility of behavior from leaking across fibers when disabling the cache in a block. ## 1.1.4 diff --git a/VERSION b/VERSION index 26aaba0..e25d8d9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.1.5 From 9f99314bfd9b48366340490f666f6d5b04f5d687 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Mon, 5 Jan 2026 20:00:14 -0800 Subject: [PATCH 04/10] Allow setting cache to memory cache with true value --- lib/support_table_cache.rb | 4 ++-- spec/support_table_cache_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/support_table_cache.rb b/lib/support_table_cache.rb index 8abeb55..d419b1a 100644 --- a/lib/support_table_cache.rb +++ b/lib/support_table_cache.rb @@ -82,10 +82,10 @@ def load_cache # Set a class-specific cache to use in lieu of the global cache. # # @param cache [ActiveSupport::Cache::Store, Symbol] The cache instance to use. You can also - # specify the value :memory to use an optimized in-memory cache. + # specify the value :memory or true to use an optimized in-memory cache. # @return [void] def support_table_cache=(cache) - cache = MemoryCache.new if cache == :memory + cache = MemoryCache.new if cache == :memory || cache == true self.support_table_cache_impl = cache end diff --git a/spec/support_table_cache_spec.rb b/spec/support_table_cache_spec.rb index ee188c1..bed6480 100644 --- a/spec/support_table_cache_spec.rb +++ b/spec/support_table_cache_spec.rb @@ -330,6 +330,13 @@ ensure TestModel.support_table_cache = nil end + + it "can set the cache to an in memory cache using true" do + TestModel.support_table_cache = true + expect(TestModel.send(:support_table_cache_impl)).to be_a(SupportTableCache::MemoryCache) + ensure + TestModel.support_table_cache = nil + end end describe "loading the cache" do From 6e1c192fbf39827086c78b27b8c41364176134bf Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Mon, 5 Jan 2026 20:01:00 -0800 Subject: [PATCH 05/10] Update change log --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a253c34..6229514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Replaced thread local variables with fiber local variables to prevent the possibility of behavior from leaking across fibers when disabling the cache in a block. +- Allow setting the cache to an in-memory cache by setting `true` to `support_table_cache` to `true`. ## 1.1.4 From 77d04a5f16ce826f4a33521baf3dbbf1bf7f238a Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Mon, 5 Jan 2026 20:50:22 -0800 Subject: [PATCH 06/10] Add table of contents --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 61d64ad..4576072 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,18 @@ end With this gem, you can avoid the database query associated with the `find_by` call. You don't need to alter your code in any way other than to include `SupportTableCache` in your model and telling it the attributes that comprise a unique key, which can be used for caching. +## Table of Contents + +- [Usage](#usage) + - [Setting the Cache](#setting-the-cache) + - [Disabling Caching](#disabling-caching) + - [Caching Belongs to Associations](#caching-belongs-to-associations) + - [Testing](#testing) + - [Companion Gems](#companion-gems) +- [Installation](#installation) +- [Contributing](#contributing) +- [License](#license) + ## Usage To use the gem, you need to include it in you models and then specify which attributes can be used for caching with the `cache_by` method. A caching attribute must be a unique key on the model. For a composite unique key, you can specify an array of attributes. If any of the attributes are case-insensitive strings, you need to specify that as well. @@ -136,10 +148,12 @@ class MiniTest::Spec ``` -### Maintaining Data +### Companion Gems You can use the companion [support_table_data gem](https://github.com/bdurand/support_table_data) to provide functionality for loading static data into your support tables as well as adding helper functions to make looking up specific rows much easier. +The [support_table](https://github.com/bdurand/support_table) gem combines both gems in a drop in solution for Rails applications. + ## Installation Add this line to your application's Gemfile: From fbc638d60a91c843c60b683227258efa38f44e74 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Mon, 5 Jan 2026 20:53:01 -0800 Subject: [PATCH 07/10] update README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4576072..f1e5c9e 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ class MiniTest::Spec You can use the companion [support_table_data gem](https://github.com/bdurand/support_table_data) to provide functionality for loading static data into your support tables as well as adding helper functions to make looking up specific rows much easier. -The [support_table](https://github.com/bdurand/support_table) gem combines both gems in a drop in solution for Rails applications. +> ![TIP] +> The [support_table](https://github.com/bdurand/support_table) gem combines both gems in a drop in solution for Rails applications. ## Installation From 690065c33853c992b55b667a475c9e050e0c8701 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Mon, 5 Jan 2026 21:03:34 -0800 Subject: [PATCH 08/10] Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6229514..a78b15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Replaced thread local variables with fiber local variables to prevent the possibility of behavior from leaking across fibers when disabling the cache in a block. -- Allow setting the cache to an in-memory cache by setting `true` to `support_table_cache` to `true`. +- Allow setting the cache to an in-memory cache by setting `support_table_cache` to `true`. ## 1.1.4 From 734eac0b7d09023d76b317a81b6759f5a5e202a3 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Mon, 5 Jan 2026 21:06:15 -0800 Subject: [PATCH 09/10] Fix markdown --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f1e5c9e..3d448bc 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ class MiniTest::Spec You can use the companion [support_table_data gem](https://github.com/bdurand/support_table_data) to provide functionality for loading static data into your support tables as well as adding helper functions to make looking up specific rows much easier. -> ![TIP] +> [!TIP] > The [support_table](https://github.com/bdurand/support_table) gem combines both gems in a drop in solution for Rails applications. ## Installation From ad1f21a2c5b15b0b8790a0cb9df5c4cf11219457 Mon Sep 17 00:00:00 2001 From: Brian Durand Date: Mon, 5 Jan 2026 22:18:15 -0800 Subject: [PATCH 10/10] fix race condition reading from fiber locals --- .github/workflows/continuous_integration.yml | 2 +- Gemfile | 1 + lib/support_table_cache/fiber_locals.rb | 8 +++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index aa096fa..091d8cf 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -55,7 +55,7 @@ jobs: bundle config set gemfile "gemfiles/${{ matrix.appraisal }}.gemfile" - name: Install gems run: | - bundle update + bundle install - name: Run Tests run: bundle exec rake - name: standardrb diff --git a/Gemfile b/Gemfile index 7d0264b..cf50c4f 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ gemspec gem "rspec", "~> 3.0" gem "rake" +gem "irb" gem "sqlite3" gem "appraisal" gem "standard", "~>1.0" diff --git a/lib/support_table_cache/fiber_locals.rb b/lib/support_table_cache/fiber_locals.rb index 9ca4074..7884ea3 100644 --- a/lib/support_table_cache/fiber_locals.rb +++ b/lib/support_table_cache/fiber_locals.rb @@ -10,7 +10,13 @@ def initialize end def [](key) - @locals[Fiber.current.object_id]&.fetch(key, nil) + fiber_locals = nil + @mutex.synchronize do + fiber_locals = @locals[Fiber.current.object_id] + end + return nil if fiber_locals.nil? + + fiber_locals[key] end def with(key, value)