ros-apartment 3.3.0 → 3.4.1

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: 8c1a77f61c7ac94816e7f0a0d906435cf2f9f06d7efc979a7308ac41a99c4247
4
+ data.tar.gz: '08bdfedeaba0107f0931a2a1f438b2dffebcdd4df89381b5be97f7bd9de5b7d3'
5
5
  SHA512:
6
- metadata.gz: 5f007c6b72251b371b02380628d53b5cf1c7b62e5620fb659b596e9dec1a62e7a2fc0bf0ad1f5253d99b852f31091ddcedf073ed5e64457c5430c58c79c8aa83
7
- data.tar.gz: 53ccf42974cebbcc6f874f9a631fe366a3952ff36d993127173df34563848359f409a3beeaa37596896a75e8c85cccd63a5ce4a7ec70b7d3278bf7e3b8c7e394
6
+ metadata.gz: c09415eddebce74732c14bf8f71ec3a4d8dab0e53f6df5d15439068964b2de79ac57c36253878456866e2021d6c8faf1a6f1a3ae12961b33d33a4118536128e3
7
+ data.tar.gz: db8413aa220b089dd0ab874051a5bf990e2f02b9a10da80e7e2356b8fbfd4a908346811ce6f8da0d9fc028c111fe9829ab495807e481a8b268353332d2e0c2bd
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
 
data/RELEASING.md ADDED
@@ -0,0 +1,106 @@
1
+ # Releasing ros-apartment
2
+
3
+ This document describes the release process for the `ros-apartment` gem.
4
+
5
+ ## Overview
6
+
7
+ Releases are automated via GitHub Actions. Pushing to `main` triggers the `gem-publish.yml` workflow, which publishes to RubyGems using trusted publishing (no API key required).
8
+
9
+ ## Prerequisites
10
+
11
+ - All changes merged to `development` branch
12
+ - CI passing on `development`
13
+ - Version number updated in `lib/apartment/version.rb`
14
+
15
+ ## Release Steps
16
+
17
+ ### 1. Bump the version
18
+
19
+ Update `lib/apartment/version.rb` on the `development` branch:
20
+
21
+ ```ruby
22
+ module Apartment
23
+ VERSION = 'X.Y.Z'
24
+ end
25
+ ```
26
+
27
+ Follow [Semantic Versioning](https://semver.org/):
28
+ - **MAJOR** (X): Breaking changes
29
+ - **MINOR** (Y): New features, backwards compatible
30
+ - **PATCH** (Z): Bug fixes, backwards compatible
31
+
32
+ ### 2. Create release PR
33
+
34
+ Create a PR from `development` to `main`:
35
+
36
+ ```bash
37
+ gh pr create --base main --head development --title "Release vX.Y.Z"
38
+ ```
39
+
40
+ Include a summary of changes in the PR description.
41
+
42
+ ### 3. Merge the release PR
43
+
44
+ Once CI passes and the PR is approved, merge it. This triggers the publish workflow.
45
+
46
+ **Important**: The workflow creates the git tag automatically. Do not create the tag manually beforehand or the workflow will fail.
47
+
48
+ ### 4. Verify the publish
49
+
50
+ Monitor the `gem-publish.yml` workflow run. It will:
51
+ 1. Build the gem
52
+ 2. Create and push the `vX.Y.Z` tag
53
+ 3. Publish to RubyGems
54
+ 4. Wait for RubyGems indexes to update
55
+
56
+ Verify at: https://rubygems.org/gems/ros-apartment
57
+
58
+ ### 5. Create GitHub Release
59
+
60
+ After the workflow completes:
61
+
62
+ 1. Go to https://github.com/rails-on-services/apartment/releases/new
63
+ 2. Select the `vX.Y.Z` tag (created by the workflow)
64
+ 3. Click "Generate release notes" for a starting point
65
+ 4. Edit the release notes to highlight key changes
66
+ 5. Publish the release
67
+
68
+ We use GitHub Releases as our changelog (no CHANGELOG.md file).
69
+
70
+ ### 6. Sync branches
71
+
72
+ Merge `main` back into `development` to keep them in sync:
73
+
74
+ ```bash
75
+ git checkout development
76
+ git pull origin development
77
+ git merge origin/main --no-edit
78
+ git push
79
+ ```
80
+
81
+ ## Workflow Details
82
+
83
+ The `gem-publish.yml` workflow uses:
84
+ - **Trusted publishing**: Configured via RubyGems.org OIDC, no API key needed
85
+ - **rubygems/release-gem@v1**: Official RubyGems action
86
+ - **rake release**: Builds gem, creates tag, pushes to RubyGems
87
+
88
+ ## Troubleshooting
89
+
90
+ ### Workflow fails with "tag already exists"
91
+
92
+ The tag was created manually before the workflow ran. Delete the tag and re-run:
93
+
94
+ ```bash
95
+ git push origin --delete vX.Y.Z
96
+ ```
97
+
98
+ Then re-trigger the workflow by pushing to main again (or re-run from GitHub Actions UI).
99
+
100
+ ### Gem published but GitHub Release missing
101
+
102
+ The GitHub Release is created manually (step 5). The gem is already available on RubyGems; the release is just for documentation.
103
+
104
+ ### RubyGems trusted publishing fails
105
+
106
+ Verify the GitHub environment `production` is configured correctly in repository settings, and that RubyGems.org has the trusted publisher configured for this repository.
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rails-on-services/apartment",
3
+ "public_key": "pk_EQhqzkh8FktmxBU0mbzmZ"
4
+ }
@@ -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
@@ -8,37 +8,47 @@ module Apartment
8
8
 
9
9
  # Migrate to latest
10
10
  def migrate(database)
11
- Tenant.switch(database) do
12
- version = ENV['VERSION']&.to_i
11
+ # Pin a connection for the entire migration to ensure Tenant.switch
12
+ # sets search_path on the same connection used by migration_context.
13
+ # Without this, connection pool may return different connections
14
+ # for the switch vs the actual migration operations.
15
+ ActiveRecord::Base.connection_pool.with_connection do
16
+ Tenant.switch(database) do
17
+ version = ENV['VERSION']&.to_i
13
18
 
14
- migration_scope_block = ->(migration) { ENV['SCOPE'].blank? || (ENV['SCOPE'] == migration.scope) }
19
+ migration_scope_block = ->(migration) { ENV['SCOPE'].blank? || (ENV['SCOPE'] == migration.scope) }
15
20
 
16
- if ActiveRecord.version >= Gem::Version.new('7.2.0')
17
- ActiveRecord::Base.connection_pool.migration_context.migrate(version, &migration_scope_block)
18
- else
19
- ActiveRecord::Base.connection.migration_context.migrate(version, &migration_scope_block)
21
+ if ActiveRecord.version >= Gem::Version.new('7.2.0')
22
+ ActiveRecord::Base.connection_pool.migration_context.migrate(version, &migration_scope_block)
23
+ else
24
+ ActiveRecord::Base.connection.migration_context.migrate(version, &migration_scope_block)
25
+ end
20
26
  end
21
27
  end
22
28
  end
23
29
 
24
30
  # Migrate up/down to a specific version
25
31
  def run(direction, database, version)
26
- Tenant.switch(database) do
27
- if ActiveRecord.version >= Gem::Version.new('7.2.0')
28
- ActiveRecord::Base.connection_pool.migration_context.run(direction, version)
29
- else
30
- ActiveRecord::Base.connection.migration_context.run(direction, version)
32
+ ActiveRecord::Base.connection_pool.with_connection do
33
+ Tenant.switch(database) do
34
+ if ActiveRecord.version >= Gem::Version.new('7.2.0')
35
+ ActiveRecord::Base.connection_pool.migration_context.run(direction, version)
36
+ else
37
+ ActiveRecord::Base.connection.migration_context.run(direction, version)
38
+ end
31
39
  end
32
40
  end
33
41
  end
34
42
 
35
43
  # rollback latest migration `step` number of times
36
44
  def rollback(database, step = 1)
37
- Tenant.switch(database) do
38
- if ActiveRecord.version >= Gem::Version.new('7.2.0')
39
- ActiveRecord::Base.connection_pool.migration_context.rollback(step)
40
- else
41
- ActiveRecord::Base.connection.migration_context.rollback(step)
45
+ ActiveRecord::Base.connection_pool.with_connection do
46
+ Tenant.switch(database) do
47
+ if ActiveRecord.version >= Gem::Version.new('7.2.0')
48
+ ActiveRecord::Base.connection_pool.migration_context.rollback(step)
49
+ else
50
+ ActiveRecord::Base.connection.migration_context.rollback(step)
51
+ end
42
52
  end
43
53
  end
44
54
  end
@@ -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.
@@ -2,12 +2,26 @@
2
2
 
3
3
  # Require this file to append Apartment rake tasks to ActiveRecord db rake tasks
4
4
  # Enabled by default in the initializer
5
+ #
6
+ # ## Multi-Database Support (Rails 7+)
7
+ #
8
+ # When a Rails app has multiple databases configured in database.yml, Rails creates
9
+ # namespaced rake tasks like `db:migrate:primary`, `db:rollback:primary`, etc.
10
+ # This enhancer automatically detects databases with `database_tasks: true` and
11
+ # enhances their namespaced tasks to also run the corresponding apartment task.
12
+ #
13
+ # Example: Running `rails db:rollback:primary` will also invoke `apartment:rollback`
14
+ # to rollback all tenant schemas.
5
15
 
6
16
  module Apartment
7
17
  class RakeTaskEnhancer
8
18
  module TASKS
9
19
  ENHANCE_BEFORE = %w[db:drop].freeze
10
20
  ENHANCE_AFTER = %w[db:migrate db:rollback db:migrate:up db:migrate:down db:migrate:redo db:seed].freeze
21
+
22
+ # Base tasks that have namespaced variants in multi-database setups
23
+ # db:seed is excluded because Rails doesn't create db:seed:primary
24
+ NAMESPACED_AFTER = %w[db:migrate db:rollback db:migrate:up db:migrate:down db:migrate:redo].freeze
11
25
  freeze
12
26
  end
13
27
 
@@ -18,36 +32,89 @@ module Apartment
18
32
  def enhance!
19
33
  return unless should_enhance?
20
34
 
21
- # insert task before
35
+ enhance_base_tasks!
36
+ enhance_namespaced_tasks!
37
+ end
38
+
39
+ def should_enhance?
40
+ Apartment.db_migrate_tenants
41
+ end
42
+
43
+ private
44
+
45
+ # Enhance standard db:* tasks (backward compatible behavior)
46
+ def enhance_base_tasks!
22
47
  TASKS::ENHANCE_BEFORE.each do |name|
23
- task = Rake::Task[name]
24
- enhance_before_task(task)
48
+ enhance_task_before(name)
25
49
  end
26
50
 
27
- # insert task after
28
51
  TASKS::ENHANCE_AFTER.each do |name|
29
- task = Rake::Task[name]
30
- enhance_after_task(task)
52
+ enhance_task_after(name)
31
53
  end
32
54
  end
33
55
 
34
- def should_enhance?
35
- Apartment.db_migrate_tenants
56
+ # Enhance namespaced db:*:database_name tasks for multi-database setups
57
+ # Maps namespaced tasks to base apartment tasks:
58
+ # db:migrate:primary -> apartment:migrate
59
+ # db:rollback:primary -> apartment:rollback
60
+ # db:migrate:up:primary -> apartment:migrate:up
61
+ def enhance_namespaced_tasks!
62
+ database_names_with_tasks.each do |db_name|
63
+ TASKS::NAMESPACED_AFTER.each do |base_task|
64
+ namespaced_task = "#{base_task}:#{db_name}"
65
+ next unless task_defined?(namespaced_task)
66
+
67
+ apartment_task = base_task.sub('db:', 'apartment:')
68
+ enhance_namespaced_task_after(namespaced_task, apartment_task)
69
+ end
70
+ end
36
71
  end
37
72
 
38
- def enhance_before_task(task)
73
+ def enhance_task_before(name)
74
+ return unless task_defined?(name)
75
+
76
+ task = Rake::Task[name]
39
77
  task.enhance([inserted_task_name(task)])
40
78
  end
41
79
 
42
- def enhance_after_task(task)
80
+ def enhance_task_after(name)
81
+ return unless task_defined?(name)
82
+
83
+ task = Rake::Task[name]
43
84
  task.enhance do
44
85
  Rake::Task[inserted_task_name(task)].invoke
45
86
  end
46
87
  end
47
88
 
89
+ def enhance_namespaced_task_after(namespaced_task_name, apartment_task_name)
90
+ Rake::Task[namespaced_task_name].enhance do
91
+ Rake::Task[apartment_task_name].invoke
92
+ end
93
+ end
94
+
48
95
  def inserted_task_name(task)
49
96
  task.name.sub('db:', 'apartment:')
50
97
  end
98
+
99
+ def task_defined?(name)
100
+ Rake::Task.task_defined?(name)
101
+ end
102
+
103
+ # Returns database names that have database_tasks enabled and are not replicas.
104
+ # These are the databases for which Rails creates namespaced rake tasks.
105
+ #
106
+ # @return [Array<String>] database names (e.g., ['primary', 'secondary'])
107
+ def database_names_with_tasks
108
+ return [] unless defined?(Rails) && Rails.respond_to?(:env)
109
+
110
+ configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
111
+ configs
112
+ .select { |c| c.database_tasks? && !c.replica? }
113
+ .map(&:name)
114
+ rescue StandardError
115
+ # Fail gracefully if configurations unavailable (e.g., during early boot)
116
+ []
117
+ end
51
118
  end
52
119
  end
53
120
  end
@@ -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.1'
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.1
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,13 +122,16 @@ 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
129
129
  - Gemfile
130
130
  - Guardfile
131
131
  - README.md
132
+ - RELEASING.md
132
133
  - Rakefile
134
+ - context7.json
133
135
  - docs/adapters.md
134
136
  - docs/architecture.md
135
137
  - docs/elevators.md
@@ -166,7 +168,9 @@ files:
166
168
  - lib/apartment/migrator.rb
167
169
  - lib/apartment/model.rb
168
170
  - lib/apartment/railtie.rb
171
+ - lib/apartment/tasks/CLAUDE.md
169
172
  - lib/apartment/tasks/enhancements.rb
173
+ - lib/apartment/tasks/schema_dumper.rb
170
174
  - lib/apartment/tasks/task_helper.rb
171
175
  - lib/apartment/tenant.rb
172
176
  - lib/apartment/version.rb
@@ -181,7 +185,6 @@ licenses:
181
185
  metadata:
182
186
  github_repo: ssh://github.com/rails-on-services/apartment
183
187
  rubygems_mfa_required: 'true'
184
- post_install_message:
185
188
  rdoc_options: []
186
189
  require_paths:
187
190
  - lib
@@ -196,8 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
196
199
  - !ruby/object:Gem::Version
197
200
  version: '0'
198
201
  requirements: []
199
- rubygems_version: 3.5.22
200
- signing_key:
202
+ rubygems_version: 3.6.9
201
203
  specification_version: 4
202
204
  summary: A Ruby gem for managing database multitenancy. Apartment Gem drop in replacement
203
205
  test_files: []