Ein FULLTEXT-Index zwingt MySQL bei JEDEM ADD COLUMN auf ALGORITHM=COPY. Auf press_releases (>1 GB) bedeutet das einen vollständigen Rebuild, während dessen kein Schreibzugriff möglich ist — im August hat genau das die Seite zum Stehen gebracht, weil die wartenden Requests PHP-FPM-Worker und DB-Verbindungen belegten. Die durchsuchbaren Felder liegen jetzt gespiegelt in `press_release_search`, die die Indizes trägt. Ihr Schema ist per Konstruktion stabil und wird nie erweitert; neue Felder gehören nach press_releases, wo sie ohne Volltext-Index Millisekunden kosten. Synchron hält sie ein Observer (alle Schreibzugriffe laufen über Eloquent, auch der Importer), reparieren lässt sie sich mit press-releases:reindex-search. Der Umstieg ist über mehrere Migrationen geteilt und schaltet erst um, wenn press_releases seine Indizes verloren hat — bis dahin sucht die Anwendung unverändert weiter. Die Drop-Migration verweigert den Dienst, solange der Suchindex unvollständig ist, und verwirft die gecachte Umschalt-Prüfung: Stünde dort noch der alte Stand, setzte der Rückfallpfad sein MATCH() auf einen soeben entfernten Index ab (MySQL-Fehler 1191) und jede Suche endete im 500er. Dazu die betriebliche Absicherung: - DB_LOCK_WAIT_TIMEOUT (Default 10 s) statt MySQLs einem Jahr. Requests scheitern bei einer laufenden Schemaänderung nach Sekunden, statt sich aufzustauen, bis keine Verbindung mehr frei ist. - Sessions und Cache nach Redis, damit eine blockierte MySQL-Tabelle nicht die gesamte Anwendung mitreißt. - db:online-alter als Notausgang (pt-online-schema-change), falls eine der kleinen FULLTEXT-Tabellen doch einmal groß wird. Percona-Toolkit ist bewusst nicht Teil des Standard-Setups. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
117 lines
3.9 KiB
PHP
117 lines
3.9 KiB
PHP
<?php
|
|
|
|
use App\Console\Commands\OnlineSchemaChange;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Symfony\Component\Console\Input\ArrayInput;
|
|
use Symfony\Component\Console\Input\InputDefinition;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* `db:online-alter` baut den Aufruf für pt-online-schema-change. Das Tool
|
|
* selbst wird hier nicht ausgeführt — geprüft werden die Schutzmechanismen
|
|
* und die Zusammenstellung der Argumente, weil ein falsches Flag auf der
|
|
* Live-Datenbank teuer ist.
|
|
*/
|
|
function ptOscArguments(array $overrides = []): array
|
|
{
|
|
$config = array_merge([
|
|
'driver' => 'mysql',
|
|
'host' => 'db.example.test',
|
|
'port' => 3306,
|
|
'database' => 'pressekonto',
|
|
'username' => 'app',
|
|
'password' => 'geheim',
|
|
], $overrides);
|
|
|
|
$command = new OnlineSchemaChange;
|
|
$command->setLaravel(app());
|
|
|
|
// Ohne --execute gilt der Trockenlauf; die Option wird hier gesetzt,
|
|
// weil buildArguments() sie liest.
|
|
(fn () => $this->input = new ArrayInput(
|
|
['--execute' => true],
|
|
new InputDefinition([
|
|
new InputOption('execute', null, InputOption::VALUE_NONE),
|
|
new InputOption('drop-old-table', null, InputOption::VALUE_NONE),
|
|
]),
|
|
))->call($command);
|
|
|
|
return $command->buildArguments($config, 'press_releases', 'ADD COLUMN gone_at TIMESTAMP NULL', '/tmp/creds.cnf');
|
|
}
|
|
|
|
test('the generated call carries the flags that keep the table writable', function () {
|
|
$arguments = ptOscArguments();
|
|
|
|
expect($arguments)
|
|
->toContain('--alter=ADD COLUMN gone_at TIMESTAMP NULL')
|
|
// 17 Fremdschlüssel zeigen auf die beiden großen Tabellen. Das
|
|
// schnellere `drop_swap` wäre kurzzeitig inkonsistent.
|
|
->toContain('--alter-foreign-keys-method=rebuild_constraints')
|
|
// pt-osc darf den laufenden Betrieb nicht ausbremsen.
|
|
->toContain('--set-vars=lock_wait_timeout=60,innodb_lock_wait_timeout=1')
|
|
->toContain('--max-load=Threads_running=40')
|
|
->toContain('--critical-load=Threads_running=100')
|
|
->toContain('--execute')
|
|
->not->toContain('--dry-run');
|
|
});
|
|
|
|
test('the credentials never appear on the command line', function () {
|
|
$arguments = ptOscArguments();
|
|
$line = implode(' ', $arguments);
|
|
|
|
// Über `ps` ist die Kommandozeile für jeden Benutzer des Servers lesbar.
|
|
expect($line)
|
|
->not->toContain('geheim')
|
|
->not->toContain('app')
|
|
->toContain('F=/tmp/creds.cnf');
|
|
});
|
|
|
|
test('the connection details land in the dsn', function () {
|
|
$arguments = ptOscArguments();
|
|
|
|
expect(end($arguments))
|
|
->toContain('h=db.example.test')
|
|
->toContain('P=3306')
|
|
->toContain('D=pressekonto')
|
|
->toContain('t=press_releases');
|
|
});
|
|
|
|
test('the old table is kept unless dropping it is requested', function () {
|
|
expect(ptOscArguments())->toContain('--no-drop-old-table');
|
|
});
|
|
|
|
test('a non mysql connection is refused instead of pretending to work', function () {
|
|
/** @var TestCase $this */
|
|
Process::fake();
|
|
|
|
// Die Testsuite läuft auf SQLite — dort richtet pt-osc nichts aus.
|
|
$this->artisan('db:online-alter', ['table' => 'users', 'alter' => 'ADD COLUMN foo INT NULL'])
|
|
->expectsOutputToContain('kein MySQL')
|
|
->assertFailed();
|
|
|
|
Process::assertNothingRan();
|
|
});
|
|
|
|
test('an unreachable database reports plainly instead of throwing', function () {
|
|
/** @var TestCase $this */
|
|
Process::fake();
|
|
|
|
config()->set('database.connections.fake_mysql', [
|
|
'driver' => 'mysql',
|
|
'host' => 'db.example.invalid',
|
|
'database' => 'pressekonto',
|
|
'username' => 'app',
|
|
'password' => 'geheim',
|
|
]);
|
|
|
|
$this->artisan('db:online-alter', [
|
|
'table' => 'press_releases',
|
|
'alter' => 'ADD COLUMN foo INT NULL',
|
|
'--connection' => 'fake_mysql',
|
|
])
|
|
->expectsOutputToContain('nicht erreichbar')
|
|
->assertFailed();
|
|
|
|
Process::assertNothingRan();
|
|
});
|