SmartCacheTenant

Gem Version

SmartCacheTenant adds tenant-aware query caching on top of ActiveRecord by storing cached read results in Rails.cache and invalidating them through lightweight version keys.

The gem is designed for multi-tenant Rails applications where the same models are queried repeatedly per tenant and cache invalidation must stay predictable.

Installation

Add the gem to your application:

gem 'smart_cache_tenant'

Then install it:

bundle install

If you are not using Bundler:

gem install smart_cache_tenant

Configuration

Create the initializer below:

# config/initializers/smart_cache_tenant.rb
SmartCacheTenant.configure do |config|
    config.enabled = true
    config.ttl = 1.hour
    config.tenant_column = :tenant_id
    config.log_queries = !Rails.env.production?
end

What each setting does

config.enabled

Globally enables or disables the gem. When it is false, relations run normally without cache reads or writes.

config.ttl

Defines how long cached query entries and internal version keys stay in Rails.cache. The default is 1.hour.

config.tenant_column

Defines which column identifies the tenant, such as :tenant_id or :account_id.

This value is used in two places:

  1. To build tenant-scoped version keys.
  2. To resolve the current tenant from relation filters or bulk write payloads.

If this is not configured, the gem still works, but cache invalidation becomes global per model instead of per tenant.

config.log_queries

Enables debug logging for cache hits. When enabled, the gem logs cached operations and their SQL through Rails.logger.

In practice, logs are only emitted when all of the following are true:

  1. config.log_queries is true.
  2. Rails.logger.debug? is true.
  3. The environment is not production.

Application Setup

1. Include callbacks in ApplicationRecord

Add the callback module once so write operations can bump the model version after commit:

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
    primary_abstract_class

    include SmartCacheTenant::ModelCallbacks
end

This is required because the gem invalidates cached reads by updating a version token after records are committed.

2. Enable caching per model

Add has_smart_cache only to models that should participate in this cache mechanism:

class Project < ApplicationRecord
    has_smart_cache
end

Only models marked with has_smart_cache are considered cache-enabled.

How It Works

For cacheable reads, the gem generates a cache key from:

  1. The current database name.
  2. The SQL generated by the relation.
  3. The current tenant identifier.
  4. A version token for each model involved in the query.
  5. The operation being executed, such as load, calculate, or exists?.

Instead of deleting cached query keys directly, the gem updates model version keys in Rails.cache. Once a version changes, all future reads produce a different cache key and the old cached entry becomes stale naturally.

This keeps invalidation simple and avoids tracking every query key that has ever been generated.

Cached Operations

The gem currently caches the following relation operations when smart cache is enabled:

  1. Record loading through relation load.
  2. Aggregate calculations through calculate, including helpers such as count, sum, average, minimum, and maximum.
  3. Existence checks through exists?.

If a relation is not cache-enabled, execution falls back to the default ActiveRecord behavior.

Invalidation Behavior

Normal model writes

When a model includes SmartCacheTenant::ModelCallbacks and declares has_smart_cache, the gem bumps that model version on after_commit.

That means create, update, and destroy operations invalidate future cached reads for the corresponding tenant.

Bulk writes

The gem also bumps versions for bulk write operations:

  1. insert_all
  2. upsert_all
  3. update_all

For bulk inserts and upserts, the gem tries to extract tenant IDs from the payload using config.tenant_column.

For update_all, the gem tries to infer the tenant from the relation filters.

If no tenant can be determined, the gem falls back to a model-wide version bump.

Usage Examples

Basic setup

class ApplicationRecord < ActiveRecord::Base
    primary_abstract_class

    include SmartCacheTenant::ModelCallbacks
end

class Project < ApplicationRecord
    has_smart_cache
end

Tenant-scoped reads

Project.where(tenant_id: current_tenant.id, active: true).to_a

On the first execution, Rails hits the database and stores the loaded records in Rails.cache.

On the next execution with the same SQL and the same tenant version, the gem returns the cached records.

Aggregate queries

Project.where(tenant_id: current_tenant.id).count
Project.where(tenant_id: current_tenant.id).sum(:budget_cents)

These calculations are cached independently from loaded records because the cache key also includes the operation name.

Existence checks

Project.where(tenant_id: current_tenant.id, slug: params[:slug]).exists?

The boolean result is cached using the relation SQL, tenant, and model version.

Automatic invalidation after commit

project = Project.create!(tenant_id: current_tenant.id, name: 'Alpha')
project.update!(name: 'Beta')
project.destroy!

Each committed write bumps the cached version for Project and the current tenant, so subsequent reads use a new cache key.

Bulk writes

Project.insert_all([
    { tenant_id: 1, name: 'Alpha' },
    { tenant_id: 1, name: 'Beta' }
])

Project.where(tenant_id: 1).update_all(active: false)

These operations also bump the version and invalidate future cached reads.

Query Scope and Multi-Model Reads

The gem tracks the main relation model and also inspects association-based query loading such as:

  1. joins
  2. left_outer_joins
  3. includes
  4. eager_load

When associations are resolved through ActiveRecord reflections, the cache key includes version tokens for all involved models.

This means a query like the one below can be invalidated by changes in either model, as long as both are cache-enabled and reachable through association metadata:

Project.includes(:tasks).where(tenant_id: current_tenant.id).to_a

Logging Cache Hits

When config.log_queries is enabled, cache hits are logged in debug mode with:

  1. The operation name.
  2. The elapsed lookup time.
  3. The generated SQL.

This is useful during rollout to confirm which queries are being served from cache.

Notes and Operational Guidance

  1. The gem depends on a configured Rails.cache backend. Use an appropriate production cache store such as Redis or Memcached.
  2. Tenant scoping is strongest when all relevant queries explicitly filter by the configured tenant column.
  3. If a query does not expose the tenant in a way the relation can resolve, invalidation may fall back to a broader model-level version bump.
  4. Because cached records are stored directly from the relation load result, use this gem only when your cache backend and application lifecycle are compatible with caching ActiveRecord result objects.
  1. Add gem 'smart_cache_tenant' to your Gemfile.
  2. Create config/initializers/smart_cache_tenant.rb and configure enabled, ttl, tenant_column, and log_queries.
  3. Add include SmartCacheTenant::ModelCallbacks to ApplicationRecord.
  4. Add has_smart_cache to each model that should participate in caching.
  5. Confirm that your queries are tenant-scoped using the configured tenant column.
  6. Verify that your Rails.cache backend is shared and persistent enough for your environment.

Development

After checking out the repo, run bin/setup to install dependencies. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

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

License

The gem is available as open source under the terms of the MIT License.