ros-apartment 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22097d27933cc7eee7bf0c5e3ad5cf1b84d9243dcfc04be00d6db07a91c9857b
4
- data.tar.gz: 28e20f76b32b532db8db681503753216ca2847c66ab80e902c200ea43a82b05d
3
+ metadata.gz: af86a04df8bf00d04859470cd76a8228d6b984da90e1324ed9eff43cbd0db0ac
4
+ data.tar.gz: 905caaf498b70015ddccc9d8846ec18384fac0a98f6dd1010890a8fa2fb19e97
5
5
  SHA512:
6
- metadata.gz: 5f007c6b72251b371b02380628d53b5cf1c7b62e5620fb659b596e9dec1a62e7a2fc0bf0ad1f5253d99b852f31091ddcedf073ed5e64457c5430c58c79c8aa83
7
- data.tar.gz: 53ccf42974cebbcc6f874f9a631fe366a3952ff36d993127173df34563848359f409a3beeaa37596896a75e8c85cccd63a5ce4a7ec70b7d3278bf7e3b8c7e394
6
+ metadata.gz: dfea9df160a8eb0e400f215eada4ecfb41852ab4d92123e1ee51bf2f1a25069c24a36a6b471048d70091a03af5efd420af6657e6bba38748630f285a5aa344cf
7
+ data.tar.gz: 9a521c51af92800ac7b42a2f7f7ef48322d075095aed7868807caecc248958fc0667e3c0ae22b14e17d9df44ada1c325ca9804e03bdc9947a2f90bcf1a6bb27a
data/.gitignore CHANGED
@@ -14,3 +14,4 @@ tmp
14
14
  spec/dummy/db/*.sqlite3
15
15
  .DS_Store
16
16
  .claude/
17
+ .mcp.json
data/.rubocop.yml CHANGED
@@ -33,6 +33,12 @@ Metrics/MethodLength:
33
33
  Exclude:
34
34
  - spec/**/*.rb
35
35
  - lib/apartment/tenant.rb
36
+ - lib/apartment/migrator.rb
37
+
38
+ Metrics/ModuleLength:
39
+ Max: 150
40
+ Exclude:
41
+ - spec/**/*.rb
36
42
 
37
43
  Metrics/AbcSize:
38
44
  Max: 20
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.3.10
1
+ 3.4.7
data/AGENTS.md ADDED
@@ -0,0 +1,19 @@
1
+ # Repository Guidelines for AI Agents
2
+
3
+ This repo uses `CLAUDE.md` files as authoritative contributor guides for all AI assistants.
4
+
5
+ ## Reading Order
6
+
7
+ 1. **Read `/CLAUDE.md` (root) first** - project-wide architecture, patterns, design decisions
8
+ 2. **Read directory-specific `CLAUDE.md`** - check the directory you're modifying and its parents
9
+ 3. **Nested files override root** on conflicts (per AGENTS.md spec)
10
+
11
+ Nested `CLAUDE.md` files exist in `lib/apartment/`, `lib/apartment/adapters/`, `lib/apartment/elevators/`, `lib/apartment/tasks/`, and `spec/`.
12
+
13
+ ## Key Principle
14
+
15
+ Check `CLAUDE.md` before copying patterns from existing code - it documents preferred patterns, design rationale, and known pitfalls.
16
+
17
+ ## Adding Documentation
18
+
19
+ Update the appropriate `CLAUDE.md` rather than this file. This file exists only as a pointer.
data/README.md CHANGED
@@ -23,6 +23,12 @@ As of May 2024, Apartment is maintained with the support of [CampusESP](https://
23
23
 
24
24
  ## Installation
25
25
 
26
+ ### Requirements
27
+
28
+ - Ruby 3.1+
29
+ - Rails 7.0+ (Rails 6.1 support was dropped in v3.4.0)
30
+ - PostgreSQL, MySQL, or SQLite3
31
+
26
32
  ### Rails
27
33
 
28
34
  Add the following to your Gemfile:
@@ -531,14 +537,55 @@ Note that you can disable the default migrating of all tenants with `db:migrate`
531
537
 
532
538
  #### Parallel Migrations
533
539
 
534
- Apartment supports parallelizing migrations into multiple threads when
535
- you have a large number of tenants. By default, parallel migrations is
536
- turned off. You can enable this by setting `parallel_migration_threads` to
537
- the number of threads you want to use in your initializer.
540
+ Apartment supports parallel tenant migrations for applications with many schemas where sequential migration time becomes problematic. This is an **advanced feature** that requires understanding of your migration safety guarantees.
541
+
542
+ ##### Enabling Parallel Migrations
543
+
544
+ ```ruby
545
+ Apartment.configure do |config|
546
+ config.parallel_migration_threads = 4
547
+ end
548
+ ```
549
+
550
+ ##### Configuration Options
551
+
552
+ | Option | Default | Description |
553
+ |--------|---------|-------------|
554
+ | `parallel_migration_threads` | `0` | Number of parallel workers. `0` disables parallelism (recommended default). |
555
+ | `parallel_strategy` | `:auto` | `:auto` detects platform, `:threads` forces thread-based, `:processes` forces fork-based. |
556
+ | `manage_advisory_locks` | `true` | Disables PostgreSQL advisory locks during parallel execution to prevent deadlocks. |
557
+
558
+ ##### Platform Considerations
559
+
560
+ Apartment auto-detects the safest parallelism strategy for your platform:
561
+
562
+ - **Linux**: Uses process-based parallelism (faster due to copy-on-write memory)
563
+ - **macOS/Windows**: Uses thread-based parallelism (avoids libpq fork issues)
564
+
565
+ You can override this with `parallel_strategy: :threads` or `parallel_strategy: :processes`, but forcing processes on macOS may cause crashes due to PostgreSQL's C library (libpq) not being fork-safe on that platform.
566
+
567
+ ##### Important: Your Responsibility
568
+
569
+ When you enable parallel migrations, Apartment disables PostgreSQL advisory locks to prevent deadlocks. This means **you are responsible for ensuring your migrations are safe to run concurrently**.
570
+
571
+ **Use parallel migrations when:**
572
+ - You have many tenants and sequential migration time is problematic
573
+ - Your migrations only modify objects within each tenant's schema
574
+ - You've verified your migrations have no cross-schema side effects
575
+
576
+ **Stick with sequential execution when:**
577
+ - Migrations create or modify PostgreSQL extensions
578
+ - Migrations modify shared types, functions, or other database-wide objects
579
+ - Migrations have ordering dependencies that span tenants
580
+ - You're unsure whether your migrations are parallel-safe
581
+
582
+ ##### Connection Pool Sizing
583
+
584
+ The `parallel_migration_threads` value should be less than your database connection pool size to avoid exhaustion errors. If you set `parallel_migration_threads: 8`, ensure your `pool` setting in `database.yml` is at least 10 to leave headroom.
585
+
586
+ ##### Schema Dump After Migration
538
587
 
539
- Keep in mind that because migrations are going to access the database,
540
- the number of threads indicated here should be less than the pool size
541
- that Rails will use to connect to your database.
588
+ Apartment automatically dumps `schema.rb` after successful migrations, ensuring the dump comes from the public schema (the source of truth). This respects Rails' `dump_schema_after_migration` setting.
542
589
 
543
590
  ### Handling Environments
544
591
 
@@ -6,11 +6,11 @@ This directory contains the core implementation of Apartment v3's multi-tenancy
6
6
 
7
7
  ```
8
8
  lib/apartment/
9
- ├── adapters/ # Database-specific tenant isolation strategies
9
+ ├── adapters/ # Database-specific tenant isolation strategies (see CLAUDE.md)
10
10
  ├── active_record/ # ActiveRecord patches and extensions
11
- ├── elevators/ # Rack middleware for automatic tenant switching
11
+ ├── elevators/ # Rack middleware for automatic tenant switching (see CLAUDE.md)
12
12
  ├── patches/ # Ruby/Rails core patches
13
- ├── tasks/ # Rake task utilities
13
+ ├── tasks/ # Rake task utilities, parallel migrations (see CLAUDE.md)
14
14
  ├── console.rb # Rails console tenant switching utilities
15
15
  ├── custom_console.rb # Enhanced console with tenant prompts
16
16
  ├── deprecation.rb # Deprecation warnings configuration
@@ -0,0 +1,107 @@
1
+ # lib/apartment/tasks/ - Rake Task Infrastructure
2
+
3
+ This directory contains modules that support Apartment's rake task operations, particularly tenant migrations with optional parallelism.
4
+
5
+ ## Problem Context
6
+
7
+ Multi-tenant PostgreSQL applications using schema-per-tenant isolation face operational challenges:
8
+
9
+ 1. **Migration time scales linearly**: 100 tenants × 2 seconds each = 3+ minutes blocking deploys
10
+ 2. **Rails assumes single-schema**: Built-in migration tasks don't iterate over tenant schemas
11
+ 3. **Parallel execution has pitfalls**: Database connections, advisory locks, and platform differences create subtle failure modes
12
+
13
+ ## Files
14
+
15
+ ### task_helper.rb
16
+
17
+ **Purpose**: Orchestrates tenant iteration for rake tasks with optional parallel execution.
18
+
19
+ **Key decisions**:
20
+
21
+ - **Result-based error handling**: Operations return `Result` structs instead of raising exceptions. This allows migrations to continue for other tenants when one fails, with aggregated reporting at the end.
22
+
23
+ - **Platform-aware parallelism**: macOS has documented issues with libpq after `fork()` due to GSS/Kerberos state. We auto-detect the platform and choose threads (safe everywhere) or processes (faster on Linux) accordingly. Developers can override via `parallel_strategy` config.
24
+
25
+ - **Advisory lock management**: Rails uses `pg_advisory_lock` to prevent concurrent migrations. With parallel tenant migrations, all workers compete for the same lock, causing deadlocks. We disable advisory locks during parallel execution. **This shifts responsibility to the developer** to ensure migrations are parallel-safe.
26
+
27
+ **When to use parallel migrations**:
28
+
29
+ Use when you have many tenants and your migrations only touch tenant-specific objects. Avoid when migrations create extensions, modify shared types, or have cross-tenant dependencies.
30
+
31
+ **Configuration options** (set in `config/initializers/apartment.rb`):
32
+
33
+ | Option | Default | Purpose |
34
+ |--------|---------|---------|
35
+ | `parallel_migration_threads` | `0` | Worker count. 0 = sequential (safest) |
36
+ | `parallel_strategy` | `:auto` | `:auto`, `:threads`, or `:processes` |
37
+ | `manage_advisory_locks` | `true` | Disable locks during parallel execution |
38
+
39
+ ### schema_dumper.rb
40
+
41
+ **Purpose**: Ensures schema is dumped from the public schema after tenant migrations.
42
+
43
+ **Why this exists**: After `rails db:migrate`, Rails dumps the current schema. Without intervention, this could capture the last-migrated tenant's schema rather than the authoritative public schema. We switch to the default tenant before invoking the dump.
44
+
45
+ **Rails convention compliance**: Respects all relevant Rails settings:
46
+ - `dump_schema_after_migration`: Global toggle for automatic dumps
47
+ - `schema_format`: `:ruby` produces schema.rb, `:sql` produces structure.sql
48
+ - `database_tasks`, `replica`, `schema_dump`: Per-database settings
49
+
50
+ ### enhancements.rb
51
+
52
+ **Purpose**: Hooks Apartment tasks into Rails' standard `db:migrate` and `db:rollback` tasks.
53
+
54
+ **Design choice**: We enhance rather than replace Rails tasks. Running `rails db:migrate` automatically migrates all tenant schemas after the public schema.
55
+
56
+ ## Relationship to Other Components
57
+
58
+ - **Apartment::Migrator** (`lib/apartment/migrator.rb`): The actual migration execution logic. TaskHelper coordinates which tenants to migrate; Migrator handles the per-tenant work.
59
+
60
+ - **Rake tasks** (`lib/tasks/apartment.rake`): Define the public task interface (`apartment:migrate`, etc.). These tasks use TaskHelper for iteration.
61
+
62
+ - **Configuration** (`lib/apartment.rb`): Parallel execution settings live in the main Apartment module.
63
+
64
+ ## Common Failure Modes
65
+
66
+ ### Connection pool exhaustion
67
+
68
+ **Symptom**: "could not obtain a connection from the pool" errors
69
+
70
+ **Cause**: `parallel_migration_threads` exceeds database pool size
71
+
72
+ **Fix**: Ensure `pool` in `database.yml` > `parallel_migration_threads`
73
+
74
+ ### Advisory lock deadlocks
75
+
76
+ **Symptom**: Migrations hang indefinitely
77
+
78
+ **Cause**: Multiple workers waiting for the same advisory lock
79
+
80
+ **Fix**: Ensure `manage_advisory_locks: true` (default) when using parallelism
81
+
82
+ ### macOS fork crashes
83
+
84
+ **Symptom**: Segfaults or GSS-API errors when using process-based parallelism on macOS
85
+
86
+ **Cause**: libpq doesn't support fork() cleanly on macOS
87
+
88
+ **Fix**: Use `parallel_strategy: :threads` or rely on `:auto` detection
89
+
90
+ ### Empty tenant name errors
91
+
92
+ **Symptom**: `PG::SyntaxError: zero-length delimited identifier`
93
+
94
+ **Cause**: `tenant_names` proc returned empty strings or nil values
95
+
96
+ **Fix**: Fixed in v3.4.0 - empty values are now filtered automatically
97
+
98
+ ## Testing Considerations
99
+
100
+ Parallel execution paths are difficult to unit test due to process isolation and connection state. The test suite verifies:
101
+
102
+ - Correct delegation between sequential/parallel paths
103
+ - Platform detection logic
104
+ - Advisory lock ENV management
105
+ - Result aggregation and error capture
106
+
107
+ Integration testing of actual parallel execution happens in CI across Linux (processes) and macOS (threads) runners.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apartment
4
+ module Tasks
5
+ # Handles automatic schema dumping after tenant migrations.
6
+ #
7
+ # ## Problem Context
8
+ #
9
+ # After running `rails db:migrate`, Rails dumps the schema to capture the
10
+ # current database structure. With Apartment, tenant migrations modify
11
+ # individual schemas but the canonical structure lives in the public/default
12
+ # schema. Without explicit handling, the schema could be dumped from the
13
+ # last-migrated tenant schema instead of the authoritative public schema.
14
+ #
15
+ # ## Why This Approach
16
+ #
17
+ # We switch to the default tenant before dumping to ensure the schema file
18
+ # reflects the public schema structure. This is correct because:
19
+ #
20
+ # 1. All tenant schemas are created from the same schema file
21
+ # 2. The public schema is the source of truth for structure
22
+ # 3. Tenant-specific data differences don't affect schema structure
23
+ #
24
+ # ## Rails Convention Compliance
25
+ #
26
+ # We respect several Rails configurations rather than inventing our own:
27
+ #
28
+ # - `config.active_record.dump_schema_after_migration`: Global toggle
29
+ # - `config.active_record.schema_format`: `:ruby` for schema.rb, `:sql` for structure.sql
30
+ # - `database_tasks: true/false`: Per-database migration responsibility
31
+ # - `replica: true`: Excludes read replicas from schema operations
32
+ # - `schema_dump: false`: Per-database schema dump toggle
33
+ #
34
+ # The `db:schema:dump` task respects `schema_format` and produces either
35
+ # schema.rb or structure.sql accordingly.
36
+ #
37
+ # ## Gotchas
38
+ #
39
+ # - Schema dump failures are logged but don't fail the migration. This
40
+ # prevents a secondary concern from blocking critical migrations.
41
+ # - Multi-database setups must mark one connection with `database_tasks: true`
42
+ # to indicate which database owns schema management.
43
+ # - Don't call `Rails.application.load_tasks` here; if invoked from a rake
44
+ # task, it re-triggers apartment enhancements causing recursion.
45
+ module SchemaDumper
46
+ class << self
47
+ # Entry point called after successful migrations. Checks all relevant
48
+ # Rails settings before attempting dump.
49
+ def dump_if_enabled
50
+ return unless rails_dump_schema_enabled?
51
+
52
+ db_config = find_schema_dump_config
53
+ return if db_config.nil?
54
+
55
+ schema_dump_setting = db_config.configuration_hash[:schema_dump]
56
+ return if schema_dump_setting == false
57
+
58
+ Apartment::Tenant.switch(Apartment.default_tenant) do
59
+ dump_schema
60
+ end
61
+ rescue StandardError => e
62
+ # Log but don't fail - schema dump is secondary to migration success
63
+ Rails.logger.warn("[Apartment] Schema dump failed: #{e.message}")
64
+ end
65
+
66
+ private
67
+
68
+ # Finds the database configuration responsible for schema management.
69
+ # Multi-database setups use `database_tasks: true` to mark the primary
70
+ # migration database. Falls back to 'primary' named config.
71
+ def find_schema_dump_config
72
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
73
+
74
+ migration_config = configs.find { |c| c.database_tasks? && !c.replica? }
75
+ return migration_config if migration_config
76
+
77
+ configs.find { |c| c.name == 'primary' }
78
+ end
79
+
80
+ # Invokes the standard Rails schema dump task. We reenable first
81
+ # because Rake tasks can only run once per session by default.
82
+ def dump_schema
83
+ if task_defined?('db:schema:dump')
84
+ Rails.logger.info('[Apartment] Dumping schema from default tenant...')
85
+ Rake::Task['db:schema:dump'].reenable
86
+ Rake::Task['db:schema:dump'].invoke
87
+ Rails.logger.info('[Apartment] Schema dump completed.')
88
+ else
89
+ Rails.logger.warn('[Apartment] db:schema:dump task not found')
90
+ end
91
+ end
92
+
93
+ # Safe task existence check. Avoids load_tasks which would cause
94
+ # recursive enhancement loading when called from apartment rake tasks.
95
+ def task_defined?(task_name)
96
+ Rake::Task.task_defined?(task_name)
97
+ end
98
+
99
+ # Checks Rails' global schema dump setting. Older Rails versions
100
+ # may not have this method, so we default to enabled.
101
+ def rails_dump_schema_enabled?
102
+ return true unless ActiveRecord::Base.respond_to?(:dump_schema_after_migration)
103
+
104
+ ActiveRecord::Base.dump_schema_after_migration
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -1,54 +1,292 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/core_ext/module/delegation'
4
+
3
5
  module Apartment
6
+ # Coordinates tenant operations for rake tasks with parallel execution support.
7
+ #
8
+ # ## Problem Context
9
+ #
10
+ # Multi-tenant applications with many schemas face slow migration times when
11
+ # running sequentially. A 100-tenant system with 2-second migrations takes
12
+ # 3+ minutes sequentially but ~20 seconds with 10 parallel workers.
13
+ #
14
+ # ## Why This Design
15
+ #
16
+ # Parallel database migrations introduce two categories of problems:
17
+ #
18
+ # 1. **Platform-specific fork safety**: macOS/Windows have issues with libpq
19
+ # (PostgreSQL C library) after fork() due to GSS/Kerberos state corruption.
20
+ # Linux handles fork() cleanly. We auto-detect and choose the safe strategy.
21
+ #
22
+ # 2. **PostgreSQL advisory lock deadlocks**: Rails uses advisory locks to
23
+ # prevent concurrent migrations. When multiple processes/threads migrate
24
+ # different schemas simultaneously, they deadlock competing for the same
25
+ # lock. We disable advisory locks during parallel execution, which means
26
+ # **you accept responsibility for ensuring your migrations are parallel-safe**.
27
+ #
28
+ # ## When to Use Parallel Migrations
29
+ #
30
+ # This is an advanced feature. Use it when:
31
+ # - You have many tenants and sequential migration time is problematic
32
+ # - Your migrations only modify tenant-specific schema objects
33
+ # - You've verified your migrations don't have cross-schema side effects
34
+ #
35
+ # Stick with sequential execution (the default) when:
36
+ # - Migrations create/modify extensions, types, or shared objects
37
+ # - Migrations have ordering dependencies across tenants
38
+ # - You're unsure whether parallel execution is safe for your use case
39
+ #
40
+ # ## Gotchas
41
+ #
42
+ # - The `parallel_migration_threads` count should be less than your connection
43
+ # pool size to avoid connection exhaustion.
44
+ # - Empty/nil tenant names from `tenant_names` proc are filtered to prevent
45
+ # PostgreSQL "zero-length delimited identifier" errors.
46
+ # - Process-based parallelism requires fresh connections in each fork;
47
+ # thread-based parallelism shares the pool but needs explicit checkout.
48
+ #
49
+ # @see Apartment.parallel_migration_threads
50
+ # @see Apartment.parallel_strategy
51
+ # @see Apartment.manage_advisory_locks
4
52
  module TaskHelper
5
- def self.each_tenant
6
- Parallel.each(tenants_without_default, in_threads: Apartment.parallel_migration_threads) do |tenant|
7
- Rails.application.executor.wrap do
8
- yield(tenant)
53
+ # Captures outcome per tenant for aggregated reporting. Allows migrations
54
+ # to continue for remaining tenants even when one fails.
55
+ Result = Struct.new(:tenant, :success, :error, keyword_init: true)
56
+
57
+ class << self
58
+ # Primary entry point for tenant iteration. Automatically selects
59
+ # sequential or parallel execution based on configuration.
60
+ #
61
+ # @yield [String] tenant name
62
+ # @return [Array<Result>] outcome for each tenant
63
+ def each_tenant(&)
64
+ return [] if tenants_without_default.empty?
65
+
66
+ if parallel_migration_threads.positive?
67
+ each_tenant_parallel(&)
68
+ else
69
+ each_tenant_sequential(&)
9
70
  end
10
71
  end
11
- end
12
72
 
13
- def self.tenants_without_default
14
- tenants - [Apartment.default_tenant]
15
- end
73
+ # Sequential execution: simpler, no connection management complexity.
74
+ # Used when parallel_migration_threads is 0 (the default).
75
+ def each_tenant_sequential
76
+ tenants_without_default.map do |tenant|
77
+ Rails.application.executor.wrap do
78
+ yield(tenant)
79
+ end
80
+ Result.new(tenant: tenant, success: true, error: nil)
81
+ rescue StandardError => e
82
+ Result.new(tenant: tenant, success: false, error: e.message)
83
+ end
84
+ end
16
85
 
17
- def self.tenants
18
- ENV['DB'] ? ENV['DB'].split(',').map(&:strip) : Apartment.tenant_names || []
19
- end
86
+ # Parallel execution wrapper. Disables advisory locks for the duration,
87
+ # then delegates to platform-appropriate parallelism strategy.
88
+ def each_tenant_parallel(&)
89
+ with_advisory_locks_disabled do
90
+ case resolve_parallel_strategy
91
+ when :processes
92
+ each_tenant_in_processes(&)
93
+ else
94
+ each_tenant_in_threads(&)
95
+ end
96
+ end
97
+ end
20
98
 
21
- def self.warn_if_tenants_empty
22
- return unless tenants.empty? && ENV['IGNORE_EMPTY_TENANTS'] != 'true'
99
+ # Process-based parallelism via fork(). Faster on Linux due to
100
+ # copy-on-write memory and no GIL contention. Each forked process
101
+ # gets isolated memory, so we must clear inherited connections
102
+ # and establish fresh ones.
103
+ def each_tenant_in_processes
104
+ Parallel.map(tenants_without_default, in_processes: parallel_migration_threads) do |tenant|
105
+ # Forked processes inherit parent's connection handles but the
106
+ # underlying sockets are invalid. Must reconnect before any DB work.
107
+ ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
108
+ reconnect_for_parallel_execution
23
109
 
24
- puts <<-WARNING
25
- [WARNING] - The list of tenants to migrate appears to be empty. This could mean a few things:
110
+ Rails.application.executor.wrap do
111
+ yield(tenant)
112
+ end
113
+ Result.new(tenant: tenant, success: true, error: nil)
114
+ rescue StandardError => e
115
+ Result.new(tenant: tenant, success: false, error: e.message)
116
+ ensure
117
+ ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
118
+ end
119
+ end
26
120
 
27
- 1. You may not have created any, in which case you can ignore this message
28
- 2. You've run `apartment:migrate` directly without loading the Rails environment
29
- * `apartment:migrate` is now deprecated. Tenants will automatically be migrated with `db:migrate`
121
+ # Thread-based parallelism. Safe on all platforms but subject to GIL
122
+ # for CPU-bound work (migrations are typically I/O-bound, so this is fine).
123
+ # Threads share the connection pool, so we reconfigure once before
124
+ # spawning and restore after completion.
125
+ def each_tenant_in_threads
126
+ original_config = ActiveRecord::Base.connection_db_config.configuration_hash
127
+ reconnect_for_parallel_execution
30
128
 
31
- Note that your tenants currently haven't been migrated. You'll need to run `db:migrate` to rectify this.
32
- WARNING
33
- end
129
+ Parallel.map(tenants_without_default, in_threads: parallel_migration_threads) do |tenant|
130
+ # Explicit connection checkout prevents pool exhaustion when
131
+ # thread count exceeds pool size minus buffer.
132
+ ActiveRecord::Base.connection_pool.with_connection do
133
+ Rails.application.executor.wrap do
134
+ yield(tenant)
135
+ end
136
+ end
137
+ Result.new(tenant: tenant, success: true, error: nil)
138
+ rescue StandardError => e
139
+ Result.new(tenant: tenant, success: false, error: e.message)
140
+ end
141
+ ensure
142
+ ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
143
+ ActiveRecord::Base.establish_connection(original_config) if original_config
144
+ end
34
145
 
35
- def self.create_tenant(tenant_name)
36
- puts("Creating #{tenant_name} tenant")
37
- Apartment::Tenant.create(tenant_name)
38
- rescue Apartment::TenantExists => e
39
- puts "Tried to create already existing tenant: #{e}"
40
- end
146
+ # Auto-detection logic for parallelism strategy. Only Linux gets
147
+ # process-based parallelism by default due to macOS libpq fork issues.
148
+ def resolve_parallel_strategy
149
+ strategy = Apartment.parallel_strategy
150
+
151
+ return :threads if strategy == :threads
152
+ return :processes if strategy == :processes
153
+
154
+ fork_safe_platform? ? :processes : :threads
155
+ end
156
+
157
+ # Platform detection. Conservative: only Linux is considered fork-safe.
158
+ # macOS has documented issues with libpq, GSS-API, and Kerberos after fork.
159
+ # See: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-GSSENCMODE
160
+ def fork_safe_platform?
161
+ RUBY_PLATFORM.include?('linux')
162
+ end
163
+
164
+ # Advisory lock management. Rails acquires pg_advisory_lock during migrations
165
+ # to prevent concurrent schema changes. With parallel tenant migrations,
166
+ # this causes deadlocks since all workers compete for the same lock.
167
+ #
168
+ # **Important**: Disabling advisory locks shifts responsibility to you.
169
+ # Your migrations must be safe to run concurrently across tenants. If your
170
+ # migrations modify shared resources, create extensions, or have other
171
+ # cross-schema side effects, parallel execution may cause failures.
172
+ # When in doubt, use sequential execution (parallel_migration_threads = 0).
173
+ #
174
+ # Uses ENV var because Rails checks it at connection establishment time,
175
+ # and we need it disabled before Parallel spawns workers.
176
+ def with_advisory_locks_disabled
177
+ return yield unless parallel_migration_threads.positive?
178
+ return yield unless Apartment.manage_advisory_locks
179
+
180
+ original_env_value = ENV.fetch('DISABLE_ADVISORY_LOCKS', nil)
181
+ begin
182
+ ENV['DISABLE_ADVISORY_LOCKS'] = 'true'
183
+ yield
184
+ ensure
185
+ if original_env_value.nil?
186
+ ENV.delete('DISABLE_ADVISORY_LOCKS')
187
+ else
188
+ ENV['DISABLE_ADVISORY_LOCKS'] = original_env_value
189
+ end
190
+ end
191
+ end
41
192
 
42
- def self.migrate_tenant(tenant_name)
43
- strategy = Apartment.db_migrate_tenant_missing_strategy
44
- create_tenant(tenant_name) if strategy == :create_tenant
193
+ # Re-establishes database connection for parallel execution.
194
+ # When manage_advisory_locks is true, disables advisory locks in the
195
+ # connection config (belt-and-suspenders with the ENV var approach).
196
+ # When false, reconnects with existing config unchanged.
197
+ def reconnect_for_parallel_execution
198
+ current_config = ActiveRecord::Base.connection_db_config.configuration_hash
45
199
 
46
- puts("Migrating #{tenant_name} tenant")
47
- Apartment::Migrator.migrate(tenant_name)
48
- rescue Apartment::TenantNotFound => e
49
- raise(e) if strategy == :raise_exception
200
+ new_config = if Apartment.manage_advisory_locks
201
+ current_config.merge(advisory_locks: false)
202
+ else
203
+ current_config
204
+ end
50
205
 
51
- puts e.message
206
+ ActiveRecord::Base.establish_connection(new_config)
207
+ end
208
+
209
+ # Delegate to Apartment.parallel_migration_threads
210
+ delegate :parallel_migration_threads, to: Apartment
211
+
212
+ # Get list of tenants excluding the default tenant
213
+ # Also filters out blank/empty tenant names to prevent errors
214
+ #
215
+ # @return [Array<String>] tenant names
216
+ def tenants_without_default
217
+ (tenants - [Apartment.default_tenant]).reject { |t| t.nil? || t.to_s.strip.empty? }
218
+ end
219
+
220
+ # Get list of all tenants to operate on
221
+ # Supports DB env var for targeting specific tenants
222
+ # Filters out blank tenant names for safety
223
+ #
224
+ # @return [Array<String>] tenant names
225
+ def tenants
226
+ result = ENV['DB'] ? ENV['DB'].split(',').map(&:strip) : Apartment.tenant_names || []
227
+ result.reject { |t| t.nil? || t.to_s.strip.empty? }
228
+ end
229
+
230
+ # Display warning if tenant list is empty
231
+ def warn_if_tenants_empty
232
+ return unless tenants.empty? && ENV['IGNORE_EMPTY_TENANTS'] != 'true'
233
+
234
+ puts <<~WARNING
235
+ [WARNING] - The list of tenants to migrate appears to be empty. This could mean a few things:
236
+
237
+ 1. You may not have created any, in which case you can ignore this message
238
+ 2. You've run `apartment:migrate` directly without loading the Rails environment
239
+ * `apartment:migrate` is now deprecated. Tenants will automatically be migrated with `db:migrate`
240
+
241
+ Note that your tenants currently haven't been migrated. You'll need to run `db:migrate` to rectify this.
242
+ WARNING
243
+ end
244
+
245
+ # Display summary of operation results
246
+ #
247
+ # @param operation [String] name of the operation (e.g., "Migration", "Rollback")
248
+ # @param results [Array<Result>] results from each_tenant
249
+ def display_summary(operation, results)
250
+ return if results.empty?
251
+
252
+ succeeded = results.count(&:success)
253
+ failed = results.reject(&:success)
254
+
255
+ puts "\n=== #{operation} Summary ==="
256
+ puts "Succeeded: #{succeeded}/#{results.size} tenants"
257
+
258
+ return if failed.empty?
259
+
260
+ puts "Failed: #{failed.size} tenants"
261
+ failed.each do |result|
262
+ puts " - #{result.tenant}: #{result.error}"
263
+ end
264
+ end
265
+
266
+ # Create a tenant with logging
267
+ #
268
+ # @param tenant_name [String] name of tenant to create
269
+ def create_tenant(tenant_name)
270
+ puts("Creating #{tenant_name} tenant")
271
+ Apartment::Tenant.create(tenant_name)
272
+ rescue Apartment::TenantExists => e
273
+ puts "Tried to create already existing tenant: #{e}"
274
+ end
275
+
276
+ # Migrate a single tenant with error handling based on strategy
277
+ #
278
+ # @param tenant_name [String] name of tenant to migrate
279
+ def migrate_tenant(tenant_name)
280
+ strategy = Apartment.db_migrate_tenant_missing_strategy
281
+ create_tenant(tenant_name) if strategy == :create_tenant
282
+
283
+ puts("Migrating #{tenant_name} tenant")
284
+ Apartment::Migrator.migrate(tenant_name)
285
+ rescue Apartment::TenantNotFound => e
286
+ raise(e) if strategy == :raise_exception
287
+
288
+ puts e.message
289
+ end
52
290
  end
53
291
  end
54
292
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '3.3.0'
4
+ VERSION = '3.4.0'
5
5
  end
data/lib/apartment.rb CHANGED
@@ -28,7 +28,8 @@ module Apartment
28
28
  WRITER_METHODS = %i[tenant_names database_schema_file excluded_models
29
29
  persistent_schemas connection_class
30
30
  db_migrate_tenants db_migrate_tenant_missing_strategy seed_data_file
31
- parallel_migration_threads pg_excluded_names].freeze
31
+ parallel_migration_threads pg_excluded_names
32
+ parallel_strategy manage_advisory_locks].freeze
32
33
 
33
34
  attr_accessor(*ACCESSOR_METHODS)
34
35
  attr_writer(*WRITER_METHODS)
@@ -93,6 +94,24 @@ module Apartment
93
94
  @parallel_migration_threads || 0
94
95
  end
95
96
 
97
+ # Parallelism strategy for migrations
98
+ # :auto (default) - Detect platform: processes on Linux, threads on macOS/Windows
99
+ # :threads - Always use threads (safer, works everywhere)
100
+ # :processes - Always use processes (faster on Linux, may crash on macOS/Windows)
101
+ def parallel_strategy
102
+ @parallel_strategy || :auto
103
+ end
104
+
105
+ # Whether to manage PostgreSQL advisory locks during parallel migrations
106
+ # When true and parallel_migration_threads > 0, advisory locks are disabled
107
+ # during migration to prevent deadlocks, then restored afterward.
108
+ # Default: true
109
+ def manage_advisory_locks
110
+ return @manage_advisory_locks if defined?(@manage_advisory_locks)
111
+
112
+ @manage_advisory_locks = true
113
+ end
114
+
96
115
  def persistent_schemas
97
116
  @persistent_schemas || []
98
117
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'apartment/migrator'
4
4
  require 'apartment/tasks/task_helper'
5
+ require 'apartment/tasks/schema_dumper'
5
6
  require 'parallel'
6
7
 
7
8
  apartment_namespace = namespace(:apartment) do
@@ -27,9 +28,22 @@ apartment_namespace = namespace(:apartment) do
27
28
  desc('Migrate all tenants')
28
29
  task(migrate: :environment) do
29
30
  Apartment::TaskHelper.warn_if_tenants_empty
30
- Apartment::TaskHelper.each_tenant do |tenant|
31
+
32
+ results = Apartment::TaskHelper.each_tenant do |tenant|
31
33
  Apartment::TaskHelper.migrate_tenant(tenant)
32
34
  end
35
+
36
+ Apartment::TaskHelper.display_summary('Migration', results)
37
+
38
+ # Dump schema after successful migrations
39
+ if results.all?(&:success)
40
+ Apartment::Tasks::SchemaDumper.dump_if_enabled
41
+ else
42
+ puts '[Apartment] Skipping schema dump due to migration failures'
43
+ end
44
+
45
+ # Exit with non-zero status if any tenant failed
46
+ exit(1) if results.any? { |r| !r.success }
33
47
  end
34
48
 
35
49
  desc('Seed all tenants')
@@ -51,14 +65,23 @@ apartment_namespace = namespace(:apartment) do
51
65
  task(rollback: :environment) do
52
66
  Apartment::TaskHelper.warn_if_tenants_empty
53
67
 
54
- step = ENV['STEP'] ? ENV['STEP'].to_i : 1
68
+ step = ENV.fetch('STEP', '1').to_i
55
69
 
56
- Apartment::TaskHelper.each_tenant do |tenant|
70
+ results = Apartment::TaskHelper.each_tenant do |tenant|
57
71
  puts("Rolling back #{tenant} tenant")
58
72
  Apartment::Migrator.rollback(tenant, step)
59
- rescue Apartment::TenantNotFound => e
60
- puts e.message
61
73
  end
74
+
75
+ Apartment::TaskHelper.display_summary('Rollback', results)
76
+
77
+ # Dump schema after successful rollback
78
+ if results.all?(&:success)
79
+ Apartment::Tasks::SchemaDumper.dump_if_enabled
80
+ else
81
+ puts '[Apartment] Skipping schema dump due to rollback failures'
82
+ end
83
+
84
+ exit(1) if results.any? { |r| !r.success }
62
85
  end
63
86
 
64
87
  namespace(:migrate) do
@@ -66,35 +89,39 @@ apartment_namespace = namespace(:apartment) do
66
89
  task(up: :environment) do
67
90
  Apartment::TaskHelper.warn_if_tenants_empty
68
91
 
69
- version = ENV['VERSION']&.to_i
92
+ version = ENV.fetch('VERSION', nil)&.to_i
70
93
  raise('VERSION is required') unless version
71
94
 
72
- Apartment::TaskHelper.each_tenant do |tenant|
95
+ results = Apartment::TaskHelper.each_tenant do |tenant|
73
96
  puts("Migrating #{tenant} tenant up")
74
97
  Apartment::Migrator.run(:up, tenant, version)
75
- rescue Apartment::TenantNotFound => e
76
- puts e.message
77
98
  end
99
+
100
+ Apartment::TaskHelper.display_summary('Migrate Up', results)
101
+ Apartment::Tasks::SchemaDumper.dump_if_enabled if results.all?(&:success)
102
+ exit(1) if results.any? { |r| !r.success }
78
103
  end
79
104
 
80
105
  desc('Runs the "down" for a given migration VERSION across all tenants.')
81
106
  task(down: :environment) do
82
107
  Apartment::TaskHelper.warn_if_tenants_empty
83
108
 
84
- version = ENV['VERSION']&.to_i
109
+ version = ENV.fetch('VERSION', nil)&.to_i
85
110
  raise('VERSION is required') unless version
86
111
 
87
- Apartment::TaskHelper.each_tenant do |tenant|
112
+ results = Apartment::TaskHelper.each_tenant do |tenant|
88
113
  puts("Migrating #{tenant} tenant down")
89
114
  Apartment::Migrator.run(:down, tenant, version)
90
- rescue Apartment::TenantNotFound => e
91
- puts e.message
92
115
  end
116
+
117
+ Apartment::TaskHelper.display_summary('Migrate Down', results)
118
+ Apartment::Tasks::SchemaDumper.dump_if_enabled if results.all?(&:success)
119
+ exit(1) if results.any? { |r| !r.success }
93
120
  end
94
121
 
95
122
  desc('Rolls back the tenant one migration and re migrate up (options: STEP=x, VERSION=x).')
96
123
  task(:redo) do
97
- if ENV['VERSION']
124
+ if ENV.fetch('VERSION', nil)
98
125
  apartment_namespace['migrate:down'].invoke
99
126
  apartment_namespace['migrate:up'].invoke
100
127
  else
@@ -32,8 +32,8 @@ Gem::Specification.new do |s|
32
32
 
33
33
  s.required_ruby_version = '>= 3.1'
34
34
 
35
- s.add_dependency('activerecord', '>= 6.1.0', '< 8.2')
36
- s.add_dependency('activesupport', '>= 6.1.0', '< 8.2')
35
+ s.add_dependency('activerecord', '>= 7.0.0', '< 8.2')
36
+ s.add_dependency('activesupport', '>= 7.0.0', '< 8.2')
37
37
  s.add_dependency('parallel', '< 2.0')
38
38
  s.add_dependency('public_suffix', '>= 2.0.5', '< 7')
39
39
  s.add_dependency('rack', '>= 1.3.6', '< 4.0')
metadata CHANGED
@@ -1,17 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ros-apartment
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner
8
8
  - Brad Robertson
9
9
  - Rui Baltazar
10
10
  - Mauricio Novelo
11
- autorequire:
12
11
  bindir: bin
13
12
  cert_chain: []
14
- date: 2025-11-24 00:00:00.000000000 Z
13
+ date: 1980-01-02 00:00:00.000000000 Z
15
14
  dependencies:
16
15
  - !ruby/object:Gem::Dependency
17
16
  name: activerecord
@@ -19,7 +18,7 @@ dependencies:
19
18
  requirements:
20
19
  - - ">="
21
20
  - !ruby/object:Gem::Version
22
- version: 6.1.0
21
+ version: 7.0.0
23
22
  - - "<"
24
23
  - !ruby/object:Gem::Version
25
24
  version: '8.2'
@@ -29,7 +28,7 @@ dependencies:
29
28
  requirements:
30
29
  - - ">="
31
30
  - !ruby/object:Gem::Version
32
- version: 6.1.0
31
+ version: 7.0.0
33
32
  - - "<"
34
33
  - !ruby/object:Gem::Version
35
34
  version: '8.2'
@@ -39,7 +38,7 @@ dependencies:
39
38
  requirements:
40
39
  - - ">="
41
40
  - !ruby/object:Gem::Version
42
- version: 6.1.0
41
+ version: 7.0.0
43
42
  - - "<"
44
43
  - !ruby/object:Gem::Version
45
44
  version: '8.2'
@@ -49,7 +48,7 @@ dependencies:
49
48
  requirements:
50
49
  - - ">="
51
50
  - !ruby/object:Gem::Version
52
- version: 6.1.0
51
+ version: 7.0.0
53
52
  - - "<"
54
53
  - !ruby/object:Gem::Version
55
54
  version: '8.2'
@@ -123,6 +122,7 @@ files:
123
122
  - ".rspec"
124
123
  - ".rubocop.yml"
125
124
  - ".ruby-version"
125
+ - AGENTS.md
126
126
  - Appraisals
127
127
  - CLAUDE.md
128
128
  - CODE_OF_CONDUCT.md
@@ -166,7 +166,9 @@ files:
166
166
  - lib/apartment/migrator.rb
167
167
  - lib/apartment/model.rb
168
168
  - lib/apartment/railtie.rb
169
+ - lib/apartment/tasks/CLAUDE.md
169
170
  - lib/apartment/tasks/enhancements.rb
171
+ - lib/apartment/tasks/schema_dumper.rb
170
172
  - lib/apartment/tasks/task_helper.rb
171
173
  - lib/apartment/tenant.rb
172
174
  - lib/apartment/version.rb
@@ -181,7 +183,6 @@ licenses:
181
183
  metadata:
182
184
  github_repo: ssh://github.com/rails-on-services/apartment
183
185
  rubygems_mfa_required: 'true'
184
- post_install_message:
185
186
  rdoc_options: []
186
187
  require_paths:
187
188
  - lib
@@ -196,8 +197,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
196
197
  - !ruby/object:Gem::Version
197
198
  version: '0'
198
199
  requirements: []
199
- rubygems_version: 3.5.22
200
- signing_key:
200
+ rubygems_version: 3.6.9
201
201
  specification_version: 4
202
202
  summary: A Ruby gem for managing database multitenancy. Apartment Gem drop in replacement
203
203
  test_files: []