diff --git a/app/Console/Commands/VacuumDatabaseCommand.php b/app/Console/Commands/VacuumDatabaseCommand.php new file mode 100644 index 000000000..7ccfc024c --- /dev/null +++ b/app/Console/Commands/VacuumDatabaseCommand.php @@ -0,0 +1,76 @@ +getDriverName() !== 'sqlite') { + $this->warn('VACUUM is only supported on SQLite connections. Skipping.'); + + return self::SUCCESS; + } + + $database = $connection->getDatabaseName(); + + if (! is_file($database)) { + $this->warn('Could not locate the SQLite database file. Skipping.'); + + return self::SUCCESS; + } + + $databaseSize = filesize($database); + + if ($databaseSize === false) { + $this->warn('Could not determine the SQLite database file size. Skipping.'); + + return self::SUCCESS; + } + + $required = $databaseSize * 2; + $available = disk_free_space(dirname($database)); + + if ($available === false) { + $this->warn('Could not determine the available disk space. Skipping.'); + + return self::SUCCESS; + } + + if ($available < $required) { + $this->warn(sprintf( + 'Not enough free disk space to vacuum safely (need ~%s, have %s). Skipping.', + $this->formatBytes($required), + $this->formatBytes((int) $available), + )); + + return self::SUCCESS; + } + + $this->info('Vacuuming the database...'); + + $connection->statement('VACUUM'); + + $this->info('Database vacuumed!'); + + return self::SUCCESS; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $power = $bytes > 0 ? (int) floor(log($bytes, 1024)) : 0; + $power = min($power, count($units) - 1); + + return round($bytes / (1024 ** $power), 2).' '.$units[$power]; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e19b35757..f966cece2 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -17,6 +17,7 @@ protected function schedule(Schedule $schedule): void $schedule->command('backups:run "0 0 * * 0"')->weekly(); $schedule->command('backups:run "0 0 1 * *"')->monthly(); $schedule->command('metrics:delete-older-metrics')->daily(); + $schedule->command('db:vacuum')->daily(); $schedule->command('metrics:get')->everyMinute(); $schedule->command('servers:check')->everyFiveMinutes(); $schedule->command('servers:check-updates')->dailyAt('02:00'); diff --git a/tests/Unit/Commands/VacuumDatabaseCommandTest.php b/tests/Unit/Commands/VacuumDatabaseCommandTest.php new file mode 100644 index 000000000..f3de3837b --- /dev/null +++ b/tests/Unit/Commands/VacuumDatabaseCommandTest.php @@ -0,0 +1,58 @@ +shouldReceive('getDriverName')->andReturn('mysql'); + $connection->shouldNotReceive('statement'); + + DB::shouldReceive('connection')->andReturn($connection); + + $this->artisan('db:vacuum') + ->expectsOutput('VACUUM is only supported on SQLite connections. Skipping.') + ->assertSuccessful(); + } + + public function test_skips_when_database_file_is_missing(): void + { + $connection = Mockery::mock(Connection::class); + $connection->shouldReceive('getDriverName')->andReturn('sqlite'); + $connection->shouldReceive('getDatabaseName')->andReturn('/path/that/does/not/exist.sqlite'); + $connection->shouldNotReceive('statement'); + + DB::shouldReceive('connection')->andReturn($connection); + + $this->artisan('db:vacuum') + ->expectsOutput('Could not locate the SQLite database file. Skipping.') + ->assertSuccessful(); + } + + public function test_vacuums_when_disk_space_is_sufficient(): void + { + $database = tempnam(sys_get_temp_dir(), 'vito-vacuum-test-'); + file_put_contents($database, 'x'); + + $connection = Mockery::mock(Connection::class); + $connection->shouldReceive('getDriverName')->andReturn('sqlite'); + $connection->shouldReceive('getDatabaseName')->andReturn($database); + $connection->shouldReceive('statement')->once()->with('VACUUM'); + + DB::shouldReceive('connection')->andReturn($connection); + + $this->artisan('db:vacuum') + ->expectsOutput('Vacuuming the database...') + ->expectsOutput('Database vacuumed!') + ->assertSuccessful(); + + unlink($database); + } +}