", z. B. "ADD COLUMN gone_at TIMESTAMP NULL"}
{--connection= : Verbindung aus config/database.php (Standard: die voreingestellte)}
{--execute : Änderung wirklich ausführen. Ohne dieses Flag läuft nur ein Trockenlauf.}
{--print-only : Nur den Befehl ausgeben, nichts starten}
{--drop-old-table : Die alte Tabelle nach Erfolg sofort löschen (spart Platte, kein Zurück)}
{--binary=pt-online-schema-change : Pfad zum pt-osc-Skript}';
protected $description = 'Ändert das Schema ohne Schreibsperre via pt-online-schema-change.';
public function handle(): int
{
$connectionName = $this->option('connection') ?: config('database.default');
$config = config("database.connections.{$connectionName}");
if (($config['driver'] ?? null) !== 'mysql') {
$this->error("Verbindung „{$connectionName}“ ist kein MySQL — pt-online-schema-change ist hier wirkungslos.");
return self::FAILURE;
}
$table = (string) $this->argument('table');
try {
if (! Schema::connection($connectionName)->hasTable($table)) {
$this->error("Tabelle „{$table}“ existiert in „{$connectionName}“ nicht.");
return self::FAILURE;
}
if (! $this->hasPrimaryKey($connectionName, $table)) {
$this->error("Tabelle „{$table}“ hat keinen Primärschlüssel — pt-online-schema-change kann sie nicht kopieren.");
return self::FAILURE;
}
} catch (QueryException $e) {
// Lieber eine klare Meldung als ein Stacktrace: Ein Tippfehler in
// der Verbindung darf nicht wie ein Programmfehler aussehen.
$this->error("Verbindung „{$connectionName}“ nicht erreichbar: ".$e->getMessage());
return self::FAILURE;
}
$binary = (string) $this->option('binary');
// Zugangsdaten NICHT als Argument übergeben: die Kommandozeile ist über
// `ps` für jeden Benutzer auf dem Server lesbar. pt-osc liest sie
// stattdessen aus einer nur für uns lesbaren Options-Datei.
$defaultsFile = $this->option('print-only')
? '/tmp/pt-osc-credentials.cnf'
: $this->writeDefaultsFile($config);
try {
$arguments = $this->buildArguments($config, $table, (string) $this->argument('alter'), $defaultsFile);
$this->line(''.$binary.' '.implode(' ', array_map(escapeshellarg(...), $arguments)).'');
if ($this->option('print-only')) {
return self::SUCCESS;
}
if (! $this->option('execute')) {
$this->warn('Trockenlauf — die Tabelle wird nicht verändert. Mit --execute wirklich ausführen.');
}
$result = Process::forever()
->run(array_merge([$binary], $arguments), function (string $type, string $output): void {
$this->output->write($output);
});
if ($result->failed()) {
$this->error('pt-online-schema-change ist fehlgeschlagen (Exit-Code '.$result->exitCode().').');
$this->line('Fehlt das Tool? Auf Ubuntu: sudo apt-get install percona-toolkit');
return self::FAILURE;
}
} finally {
if (! $this->option('print-only') && is_file($defaultsFile)) {
@unlink($defaultsFile);
}
}
if ($this->option('execute')) {
$this->info('Fertig. Jetzt „php artisan migrate --force“ nachziehen, damit die Migration vermerkt wird.');
if (! $this->option('drop-old-table')) {
$this->warn(sprintf(
'Die Originaltabelle liegt als `_%s_old` weiter auf der Platte (voller Tabellenumfang). Nach der Kontrolle entfernen: DROP TABLE `_%s_old`;',
$table,
$table,
));
}
}
return self::SUCCESS;
}
/**
* @param array $config
* @return list
*/
public function buildArguments(array $config, string $table, string $alter, string $defaultsFile): array
{
$dsn = implode(',', [
'F='.$defaultsFile,
'h='.($config['host'] ?? '127.0.0.1'),
'P='.($config['port'] ?? 3306),
'D='.$config['database'],
't='.$table,
]);
return [
'--alter='.$alter,
// Fremdschlüssel: 17 Constraints zeigen auf press_releases und
// companies. `rebuild_constraints` hängt die Kind-Tabellen nach dem
// Tausch sauber um — mit abgeschalteter Prüfung dauert das selbst
// bei einer Million Zeilen unter 200 ms. Die Alternative
// `drop_swap` wäre kurz inkonsistent und wird bewusst vermieden.
'--alter-foreign-keys-method=rebuild_constraints',
// Kopiert wird in Blöcken von je einer halben Sekunde, damit die
// Tabelle zwischendurch für den normalen Betrieb frei ist.
'--chunk-time=0.5',
// Bei Last pausieren statt die Seite mitzureißen.
'--max-load=Threads_running=40',
'--critical-load=Threads_running=100',
// Auch pt-osc selbst darf nicht ewig auf eine Sperre warten.
// `innodb_lock_wait_timeout=1` lässt die Kopier-Blöcke sofort
// zurückweichen, statt den laufenden Betrieb auszubremsen.
'--set-vars=lock_wait_timeout=60,innodb_lock_wait_timeout=1',
// Nach Erfolg bleibt die Originaltabelle als `__old` liegen —
// das Sicherheitsnetz, falls doch etwas fehlt. Sie belegt aber den
// vollen Platz der Tabelle und muss von Hand entfernt werden.
...($this->option('drop-old-table') ? [] : ['--no-drop-old-table']),
'--print',
'--progress=time,30',
$this->option('execute') ? '--execute' : '--dry-run',
$dsn,
];
}
/**
* @param array $config
*/
private function writeDefaultsFile(array $config): string
{
$path = tempnam(sys_get_temp_dir(), 'pt-osc-');
chmod($path, 0600);
file_put_contents($path, sprintf(
"[client]\nuser=%s\npassword=%s\n",
self::quoteOptionValue((string) ($config['username'] ?? '')),
self::quoteOptionValue((string) ($config['password'] ?? '')),
));
return $path;
}
/**
* Maskiert einen Wert für eine my.cnf-Optionsdatei.
*
* Ohne Anführungszeichen beginnt ein `#` im Wert einen Kommentar und
* umschließende Leerzeichen fallen weg — ein Passwort mit Raute käme also
* abgeschnitten bei pt-osc an, und der Lauf scheiterte an einem
* Authentifizierungsfehler, dessen Ursache nirgends sichtbar ist.
*/
private static function quoteOptionValue(string $value): string
{
return '"'.str_replace(['\\', '"'], ['\\\\', '\\"'], $value).'"';
}
private function hasPrimaryKey(string $connection, string $table): bool
{
return collect(DB::connection($connection)->select('SHOW INDEX FROM `'.$table.'`'))
->contains(fn (object $index): bool => ($index->Key_name ?? null) === 'PRIMARY');
}
}