From 3498a3f9c2e3d22ebef822ef34e1d31f1cd47df1 Mon Sep 17 00:00:00 2001 From: khaledSayed93 Date: Sat, 28 Mar 2026 23:09:42 +0200 Subject: [PATCH] Enhance tenant migration command to support parallel execution - Added a `--parallel` option to the Migrate command for concurrent tenant migrations. - Introduced a `parallel-batch-size` option to control the number of concurrent migrations. - Implemented a new method `runParallel` to handle parallel migrations. - Updated the `handle` method to initiate parallel migrations when the option is set. - Added tests to verify the functionality of the parallel migration option. --- src/Commands/Migrate.php | 92 +++++++++++++++++--- src/Concerns/ParallelTenantMigrator.php | 108 ++++++++++++++++++++++++ tests/CommandsTest.php | 29 +++++++ 3 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 src/Concerns/ParallelTenantMigrator.php diff --git a/src/Commands/Migrate.php b/src/Commands/Migrate.php index c67d3598..613b25d1 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -10,12 +10,18 @@ use Illuminate\Database\Migrations\Migrator; use Stancl\Tenancy\Concerns\DealsWithMigrations; use Stancl\Tenancy\Concerns\ExtendsLaravelCommand; use Stancl\Tenancy\Concerns\HasATenantsOption; +use Stancl\Tenancy\Concerns\ParallelTenantMigrator; +use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\DatabaseMigrated; use Stancl\Tenancy\Events\MigratingDatabase; +use Symfony\Component\Console\Input\InputOption; class Migrate extends MigrateCommand { - use HasATenantsOption, DealsWithMigrations, ExtendsLaravelCommand; + use HasATenantsOption { + getOptions as private tenantsCommandOptions; + } + use DealsWithMigrations, ExtendsLaravelCommand, ParallelTenantMigrator; protected $description = 'Run migrations for tenant(s)'; @@ -31,12 +37,20 @@ class Migrate extends MigrateCommand $this->specifyParameters(); } - /** - * Execute the console command. - * - * @return mixed - */ - public function handle() + protected function getOptions() + { + return array_merge([ + [ + 'parallel-batch-size', + null, + InputOption::VALUE_OPTIONAL, + 'Maximum concurrent tenant migrations per batch when using --parallel', + '10', + ], + ], $this->tenantsCommandOptions()); + } + + public function handle(): int { foreach (config('tenancy.migration_parameters') as $parameter => $value) { if (! $this->input->hasParameterOption($parameter)) { @@ -45,15 +59,73 @@ class Migrate extends MigrateCommand } if (! $this->confirmToProceed()) { - return; + return static::FAILURE; } - tenancy()->runForMultiple($this->option('tenants'), function ($tenant) { + if ($this->option('parallel')) { + return $this->runParallel(); + } + + $this->runMigrationForTenants($this->option('tenants')); + + return static::SUCCESS; + } + + private function runParallel(): int + { + if (! class_exists(\Illuminate\Support\Facades\Concurrency::class)) { + $this->error('Parallel tenant migrations require Laravel 11 or newer (Concurrency facade).'); + + return static::FAILURE; + } + + $keys = $this->tenantKeys(); + if ($keys === []) { + $this->info('No tenants to migrate.'); + + return static::SUCCESS; + } + + $this->info('Running migrations in parallel'); + + $this->runParallelTenantBatches( + $keys, + max(1, (int) $this->option('parallel-batch-size')), + function (int $batchIndex, int $batchTotal, array $batch): void { + $n = count($batch); + $this->line(sprintf( + 'Parallel batch %d/%d (%d tenant%s)', + $batchIndex + 1, + $batchTotal, + $n, + $n === 1 ? '' : 's' + )); + } + ); + + return static::SUCCESS; + } + + /** + * @return list + */ + private function tenantKeys(): array + { + $keys = []; + foreach ($this->getTenants() as $tenant) { + $keys[] = $tenant instanceof Tenant ? $tenant->getTenantKey() : $tenant; + } + + return $keys; + } + + protected function runMigrationForTenants(?array $tenants = []): void + { + tenancy()->runForMultiple($tenants, function ($tenant) { $this->line("Tenant: {$tenant->getTenantKey()}"); event(new MigratingDatabase($tenant)); - // Migrate parent::handle(); event(new DatabaseMigrated($tenant)); diff --git a/src/Concerns/ParallelTenantMigrator.php b/src/Concerns/ParallelTenantMigrator.php new file mode 100644 index 00000000..aa567926 --- /dev/null +++ b/src/Concerns/ParallelTenantMigrator.php @@ -0,0 +1,108 @@ + + */ + protected function optionsForNestedMigrateCall(): array + { + $options = []; + + if ($database = $this->option('database')) { + $options['--database'] = $database; + } + + if ($this->option('force')) { + $options['--force'] = true; + } + + if ($paths = $this->option('path')) { + $options['--path'] = $paths; + } + + if ($this->option('realpath')) { + $options['--realpath'] = true; + } + + if ($schemaPath = $this->option('schema-path')) { + $options['--schema-path'] = $schemaPath; + } + + if ($this->option('pretend')) { + $options['--pretend'] = true; + } + + if ($this->option('seed')) { + $options['--seed'] = true; + } + + if ($seeder = $this->option('seeder')) { + $options['--seeder'] = $seeder; + } + + if ($this->option('step')) { + $options['--step'] = true; + } + + if ($this->option('graceful')) { + $options['--graceful'] = true; + } + + if ($this->option('no-interaction')) { + $options['--no-interaction'] = true; + } + + return $options; + } + + /** + * @param list $keys + * @param callable(int $batchIndex, int $batchTotal, list $batchKeys): void $beforeBatch + */ + protected function runParallelTenantBatches(array $keys, int $batchSize, callable $beforeBatch): void + { + $forward = $this->optionsForNestedMigrateCall(); + $batches = array_chunk($keys, max(1, $batchSize)); + $total = count($batches); + + foreach ($batches as $i => $batch) { + $beforeBatch($i, $total, $batch); + + $tasks = []; + foreach ($batch as $key) { + $tasks[] = static fn () => self::migrateOneTenantViaArtisan($key, $forward); + } + + Concurrency::run($tasks); + } + } + + /** + * @param array $forwardOptions + */ + private static function migrateOneTenantViaArtisan(string|int $key, array $forwardOptions): void + { + $code = Artisan::call('tenants:migrate', array_merge($forwardOptions, [ + '--tenants' => [$key], + '--force' => true, + ])); + + if ($code !== 0) { + throw new RuntimeException("Tenant migration failed for [{$key}] with exit code {$code}."); + } + } +} diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index eabb0aaa..4d168669 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Concurrency; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Schema; @@ -92,6 +93,34 @@ class CommandsTest extends TestCase $this->assertTrue(Schema::hasTable('users')); } + #[Test] + public function migrate_command_works_with_parallel_option() + { + if (! class_exists(Concurrency::class)) { + $this->markTestSkipped('Parallel tenant migrations require the Concurrency facade (Laravel 11+).'); + } + + config(['concurrency.default' => 'sync']); + + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + $this->assertFalse(Schema::hasTable('users')); + + Artisan::call('tenants:migrate', [ + '--parallel' => true, + ]); + + $this->assertFalse(Schema::hasTable('users')); + + tenancy()->initialize($tenant1); + $this->assertTrue(Schema::hasTable('users')); + tenancy()->end(); + + tenancy()->initialize($tenant2); + $this->assertTrue(Schema::hasTable('users')); + } + #[Test] public function rollback_command_works() {