intertwingly

It’s just data

ActiveRecord::Tenanted Needs Geo-Aware Lazy Migrations


ActiveRecord::Tenanted, announced at RailsWorld 2025, is an exciting step forward for multi-tenant Rails applications. It brings database-per-tenant architecture into the mainstream with first-class Rails integration.

But there's a problem: its migration strategy won't scale to geographically distributed deployments.

With DHH also announcing Kamal Geo Proxy at the same conference, it's clear that geo-distributed Rails apps are becoming a first-class deployment pattern. ActiveRecord::Tenanted needs to evolve to support this architecture.

I've been running Showcase—a geographically distributed multi-tenant application serving 70+ sites across 8 countries—in production for over 3 years. The migration patterns I've developed are battle-tested and ready to be adopted by ActiveRecord::Tenanted.

This post advocates for ActiveRecord::Tenanted to become geo-aware and support lazy migrations.

The Problem with ActiveRecord::Tenanted's Current Approach

ActiveRecord::Tenanted (v0.4.1) uses eager synchronous migrations. Every database must be fully migrated before it can be accessed:

This works fine for a single Rails application. But it breaks down in geo-distributed architectures where:

  1. Hundreds or even thousands of databases need migrating across multiple machines
  2. Different regions host different subsets of tenants
  3. Migrations block deployments: Kamal provides zero-downtime for Rails, but waits until all migrations complete before routing traffic to new containers and decommissioning old ones
  4. Coordination across machines becomes complex

How Showcase Solves This

Showcase uses lazy migrations with background preparation and geographic awareness. Here's how it works:

1. Geographic Awareness

Each machine knows which databases it's responsible for via tmp/tenants.list. This file is generated based on region/machine configuration, ensuring:

2. Background Migration During Startup

On deployment, the server starts in maintenance mode and runs bin/prerender, which:

Once background migrations start (but don't have to finish), the server switches out of maintenance mode and begins serving traffic.

3. On-Demand Safety Net

If a request arrives for a database that hasn't been migrated yet, config.ru runs the migration on-demand before starting the Rails instance. This ensures correctness even when background migration hasn't completed.

4. File-Based Locking

.lock files prevent concurrent migrations of the same database across multiple processes on the same machine.

Why This Works

Performance: The fast-path check (SELECT version FROM schema_migrations) is ~1000× faster than ActiveRecord's pending_migrations check, taking only 5-10ms per database.

Zero-downtime: Traffic starts flowing immediately after deployment. Background migration happens concurrently.

Geographic efficiency: Each region only migrates its own databases—no wasted work.

Resilience: The on-demand safety net in config.ru ensures correctness even if background migration hasn't finished.

What ActiveRecord::Tenanted Needs

For ActiveRecord::Tenanted to work with Kamal Geo Proxy and geographically distributed deployments, it needs:

1. Tenant Filtering API

# Configure which tenants this machine is responsible for
# The exact API will depend on Kamal Geo Proxy's design
config.active_record.tenanted.tenant_filter = ->(name) do
  # Application-specific logic to determine which tenants
  # this machine is responsible for migrating
  # All other tenants are treated as read-only
end

This allows each machine to know which databases it's responsible for migrating. Any tenant not matching the filter is treated as read-only (hosted elsewhere). The implementation will depend on how Kamal Geo Proxy handles routing and region assignment.

2. Background Migration Task

# Non-blocking migration that returns immediately
rake db:migrate:tenant:background

# Or with filtering
rake db:migrate:tenant:background REGION=iad

The task should:

3. Lazy Migration Mode

# In config/application.rb
config.active_record.tenanted.migration_mode = :lazy

# This changes connection pool behavior:
# - Don't raise PendingMigrationError
# - Run migration on-demand when accessing unmigrated database (only for filtered tenants)
# - Skip migrations entirely for tenants not matching the filter
# - Use fast-path check (query schema_migrations directly)

4. Fast-Path Migration Check

Replace the expensive pending_migrations check with a direct query:

def migrations_pending?(tenant_name)
  applied = execute("SELECT version FROM schema_migrations").flatten
  (migration_versions - applied).any?
end

This is ~1000× faster and makes the lazy check negligible.

5. Built-in Throttling

# In config/application.rb
config.active_record.tenanted.migration_throttle = 1.second # Sleep between migrations

Proposed API

Here's what it could look like:

# config/application.rb
config.active_record.tenanted.migration_mode = :lazy
config.active_record.tenanted.migration_throttle = 1.second

# Configure which tenants this machine is responsible for
# The exact logic will depend on your infrastructure and Kamal Geo Proxy's design
config.active_record.tenanted.tenant_filter = ->(name) do
  # Example: filter by region prefix, machine ID, or other criteria
  # This is application-specific
  # All other tenants are treated as read-only
end

Why This Matters

ActiveRecord::Tenanted is the future of multi-tenancy in Rails. But to fulfill that promise, it needs to work with Kamal Geo Proxy and distributed deployments.

The patterns in Showcase have been battle-tested for 3+ years across:

These aren't experimental ideas—they're proven patterns ready to be adopted.

A Path Forward

I'm not asking ActiveRecord::Tenanted to abandon its current approach. Eager synchronous migrations are perfect for single-application deployments.

I'm asking for options. Let users choose:

Both modes can coexist. The lazy mode could even be opt-in via configuration.

Let's Make It Happen

If you're working on ActiveRecord::Tenanted or thinking about distributed multi-tenancy, I'd love to collaborate. The code is in Showcase—it's open source and ready to be adapted.

Discussion of this proposal is taking place at activerecord-tenanted#213.

My goal is to eventually migrate Showcase to use ActiveRecord::Tenanted. But that requires these features. I suspect many others have similar needs.

Rails deserves a multi-tenancy solution that works everywhere—from a single DigitalOcean Droplet to a globally distributed Kamal deployment.

Let's build it together.


Resources: