ros-apartment Documentation

repository·main·Indexed 19 days ago

https://github.com/rails-on-services/apartment

A database-level multitenancy solution for Rails and ActiveRecord. ros-apartment is a maintained fork of the original Apartment gem that isolates tenant data using PostgreSQL schemas or separate databases (MySQL/SQLite) to ensure hard data separation enforced by the database engine. It includes features for tenant lifecycle management, request-based tenant detection via Elevators, connection pool configuration, and support for parallel migrations.

Tokens
99.5K
Snippets
244
Records
451
Agent score
61%

What's inside ros-apartment

  1. What is Apartment and when to use it

    main

    Apartment provides database-level multitenancy for Rails and ActiveRecord. It isolates tenant data using either schema-per-tenant (PostgreSQL) or database-per-tenant (MySQL/SQLite). This ensures data separation is enforced by the database engine rather than application logic.

    When to use Apartment

    Use Apartment when you require hard data isolation, such as in regulated industries or B2B SaaS with contractual isolation requirements. It is ideal for:

    • Fewer high-value tenants.
    • Regulatory compliance requirements.
    • Retrofitting existing single-tenant applications.

    When to use Row-level tenancy instead

    If you have hundreds of tenants, need to perform cross-tenant reporting, or are building a greenfield application, consider row-level tenancy (e.g., using acts_as_tenant) as it is simpler and scales more linearly.

  2. What is Apartment::PoolObserver?

    main

    Apartment::PoolObserver is a sink-agnostic observer designed for the v4 pool lifecycle. It subscribes to the gem's ActiveSupport::Notifications and forwards normalized Sample value objects to a caller-supplied sink.

    Key characteristics:

    • Sink-agnostic: It does not ship with a transport layer (like CloudWatch or StatsD). The user's sink is responsible for mapping Sample objects to their preferred metrics backend.
    • Dual-mode telemetry: It handles both counter events (via event subscription) and gauge samples (via an optional periodic sampler).
    • Error-isolated: All calls to the sink or the sampler are wrapped in error handling to ensure telemetry failures never interrupt the gem's core instrumentation or timer paths.
  3. Manage connection demand in v4 with pool-per-tenant

    main

    Apartment v4 uses a pool-per-tenant model. This means each unique tenant:role combination maintains its own connection pool.

    Key implication: Total connection demand scales with the number of distinct tenants each process touches, rather than the number of threads. A worker handling a wide variety of tenants will consume significantly more database connections than a v3 worker.

    To prevent exhausting your database connection limit, use the following configuration knobs to bound the connection footprint.

  4. Understand Pool Admission Control and capacity bounds

    main

    Apart uses Synchronous Admission Control to enforce limits on the number of tenant pools. This prevents a burst of new tenant requests from creating more pools than the database backend can handle.

    Capacity Calculation

    The actual bound used for admission is the effective_pool_budget, which is derived from your configuration:

    effective_pool_budget = min(max_tenant_pools, floor(max_tenant_connections / tenant_pool_size))

    Note: max_tenant_pools is the current configuration key; max_total_connections is a deprecated alias (removed in v5).

  5. Automatic tenant detection with Elevators

    main

    Apartment v4 uses 'Elevators' (Rack middleware) to automatically detect and switch the tenant context based on incoming web requests.

    Supported detection strategies include:

    • Subdomain: Based on the request subdomain.
    • FirstSubdomain: Based on the first part of the subdomain.
    • Domain: Based on the request domain.
    • Host: Based on the request host.
    • HostHash: Based on a hash of the host.
    • Header: (New in v4) Uses a trusted HTTP header to resolve the tenant.

    You can configure these via elevator_options in your application configuration.

  6. How `with_advisory_locks_disabled` handles ActiveRecord connection state

    main

    The Apartment::Migrator#with_advisory_locks_disabled method is used to disable PostgreSQL advisory locks on a leased connection to prevent parallel tenant migrations from serializing.

    Because ActiveRecord does not provide a public setter for advisory lock state, Apartment directly manipulates the private @advisory_locks_enabled instance variable. To prevent silent failures if a future version of Rails renames this internal variable, the method implements a guard:

    1. It checks if the connection defines the ADVISORY_LOCKS_IVAR (:@advisory_locks_enabled) using instance_variable_defined?.
    2. If defined: It toggles the variable to false, yields the block, and restores the original value in an ensure block.
    3. If NOT defined: It issues a warning (matching /cannot disable advisory locks/i) and yields the block without attempting to modify the connection. This prevents creating an 'orphan' instance variable that would fail to control actual Rails behavior.
        def with_advisory_locks_disabled
          conn = ActiveRecord::Base.lease_connection
          unless conn.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
            warn "[Apartment::Migrator] ActiveRecord connection #{conn.class} does not define " \
                 "#{ADVISORY_LOCKS_IVAR}; cannot disable advisory locks for this Rails version. " \
                 'Parallel tenant migrations will serialize on the database-wide advisory lock.'
            return yield
          end
          original = conn.instance_variable_get(ADVISORY_LOCKS_IVAR)
          conn.instance_variable_set(ADVISORY_LOCKS_IVAR, false)
          yield
        ensure
          if conn&.instance_variable_defined?(ADVISORY_LOCKS_IVAR)
            conn.instance_variable_set(ADVISORY_LOCKS_IVAR, original)
          end
        end
  7. How PostgreSQL sequence_name memoization works in Apartment v4

    main

    In Apartment v4, ActiveRecord's class-level Model.sequence_name memoization can cause cross-tenant issues if not handled correctly.

    The Problem: Rails resolves default_sequence_name (via pg_get_serial_sequence) using an unqualified table name. PostgreSQL returns a schema-qualified name based on the current connection's search_path. ActiveRecord then memoizes this schema-qualified name once per model class, process-wide. If a consumer (like activerecord-import) renders this memoized value into a literal nextval(...) call, every subsequent tenant will attempt to draw IDs from the first tenant's sequence, leading to wrong-tenant IDs, silent sequence drift, and PG::UniqueViolation errors.

    The Solution: Apartment uses the Apartment::Patches::PostgresqlSequenceName patch to strip the connection's own current_schema prefix from the resolved sequence name. This makes the memoized value schema-agnostic (e.g., widgets_id_seq instead of tenant_a.widgets_id_seq). When nextval() is called, it re-resolves through the current tenant's search_path, ensuring the correct sequence is used.

    Important Note on Qualification:

    • Prefixes naming the current schema are stripped.
    • Prefixes naming other schemas (such as those used for persistent schemas or pinned models in the default_tenant) are preserved. This is necessary to ensure pinned models continue to draw from the correct default tenant even when a tenant switch is active.
    # Example of the behavior being addressed:
    # If seq_a resolves 'widgets_id_seq' to 'seq_a.widgets_id_seq'
    # The patch ensures the memoized value is just 'widgets_id_seq'
    # so that when switched to 'seq_b', nextval('widgets_id_seq') 
    # correctly points to 'seq_b.widgets_id_seq'.
  8. Limitations of `:reading` role support

    main

    When using the :reading role in Apartment, be aware of the following architectural boundaries:

    1. No Write-Through: The :reading role does not support writing through to the database.
    2. No Cross-Role Visibility: A :reading role connection will not necessarily see writes made by a :writing role within the same test/transaction. This is a known limitation (recorded as failure-class member 10).
    3. No Physical Replication: The current implementation focuses on the role axis (handling multiple roles on the same database) rather than the multi-DB axis (handling actual streaming replicas or separate physical databases).
  9. Establish tenant context in RSpec before(:each) hooks

    main

    In rspec-rails (8.x+), Apartment::Current is automatically reset to nil before every example via ActiveSupport::CurrentAttributes.clear_all. This prevents tenant context from leaking between tests.

    Because of this reset, you cannot establish tenant context in suite-level bootstrap or global config.around hooks; they will be wiped before the test body runs. You must establish the tenant context inside a before(:each) hook to ensure it survives into the example body.

  10. How TenantValidator handles lifecycle and errors

    main

    The Apartment::TenantValidator is an in-process, memoized validator used to determine if a tenant name is valid. It uses a 'positive set' of names sourced from config.tenants_provider.

    Key Behaviors:

    • Lifecycle Invalidation: The validator subscribes to ActiveSupport::Notifications for create.apartment and drop.apartment. When these notifications are emitted, the validator automatically adds or removes the tenant from its internal set without requiring a full rebuild.
    • Fail-Open Mechanism: If the tenants_provider raises an error (e.g., a database or external service is down), the validator enters a degraded state. In this state, it fails open, meaning it returns true for all tenant names to prevent the application from returning 404s for all requests during a provider outage.
    • Stale Handling: When degraded, the validator uses a shorter rebuild_interval to retry the provider more frequently, rather than waiting for the standard positive_ttl.
    # Example of how the validator behaves during a provider failure
    # (Conceptual based on implementation logic)
    configure(-> { raise StandardError, 'provider down' })
    validator = Apartment::TenantValidator.new
    validator.call('any_tenant') # => true (fails open)
  11. Handle cross-pool transaction visibility in tests

    main

    Apartment v4 uses pool-per-tenant connection routing. This means a transaction held on one tenant's connection pool is invisible to another tenant's connection pool, even within the same test.

    If your test requires reading data written by a different tenant (cross-pool reads), standard transactional fixtures (like Rails' use_transactional_fixtures or DatabaseCleaner :transaction) will fail because the uncommitted rows are invisible to the second pool.

    Solution: For specs requiring cross-pool visibility, switch to a deletion or truncation strategy (e.g., DatabaseCleaner.strategy = :deletion).

    RSpec.shared_context 'cross-tenant', cross_tenant: true do
      before do
        DatabaseCleaner.strategy = :deletion
        DatabaseCleaner.start
      end
    
      after { DatabaseCleaner.clean }
    end
    
    # Use it in a spec
    RSpec.describe MyJob, cross_tenant: true do
      # ...
    end