technik: Volltext-Indizes von press_releases in eine eigene Suchtabelle auslagern
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>
This commit is contained in:
parent
e6eca9c85f
commit
8fc18c239b
14 changed files with 1076 additions and 5 deletions
|
|
@ -69,6 +69,10 @@ DB_CONNECTION=sqlite
|
|||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
# Wartezeit auf Tabellen-Sperren in Sekunden (siehe config/database.php).
|
||||
# Migrationen auf großen Tabellen ggf. mit DB_LOCK_WAIT_TIMEOUT=300 starten.
|
||||
DB_LOCK_WAIT_TIMEOUT=10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy-Datenbanken (Alt-Systeme, read-only für Migration/Abgleich).
|
||||
# Die businessportal-Verbindung ist ein Snapshot-Dump, nicht live.
|
||||
|
|
@ -83,7 +87,7 @@ DB_DATABASE_PRESSEECHO=
|
|||
DB_USERNAME_PRESSEECHO=
|
||||
DB_PASSWORD_PRESSEECHO=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
|
|
@ -95,7 +99,7 @@ BROADCAST_CONNECTION=log
|
|||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=redis
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
|
|
|||
36
CLAUDE.md
36
CLAUDE.md
|
|
@ -115,6 +115,42 @@ The project uses a **dual-port Vite setup** with separate configurations:
|
|||
- **Migrations**: Users (with 2FA columns), cache, jobs, personal access tokens
|
||||
- **Seeders**: `RolesAndPermissionsSeeder` - Run with `php artisan migrate --seed`
|
||||
|
||||
#### Schema changes and FULLTEXT indexes (IMPORTANT)
|
||||
|
||||
**Any** FULLTEXT index on a table forces MySQL to fall back to
|
||||
`ALGORITHM=COPY` for `ADD COLUMN` — even a single one, despite the misleading
|
||||
error text ("supports one FULLTEXT index creation at a time"). The whole table
|
||||
is rebuilt and **all writes to it block** for the duration.
|
||||
|
||||
`press_releases` (>1 GB) is therefore kept free of FULLTEXT indexes. Its
|
||||
searchable fields are mirrored into **`press_release_search`**, which carries
|
||||
the indexes instead:
|
||||
|
||||
- Kept in sync by `PressReleaseSearchObserver` (all writes, including the
|
||||
legacy importer, go through Eloquent).
|
||||
- Repairable with `php artisan press-releases:reindex-search` (`--all` rewrites
|
||||
every row). Run it after any bulk update done through the query builder —
|
||||
those do not fire model events.
|
||||
- **Never add columns to `press_release_search`.** New fields belong on
|
||||
`press_releases`, where they cost milliseconds.
|
||||
|
||||
The remaining FULLTEXT tables (`companies` 70 MB, `users` 25 MB, `contacts`
|
||||
6 MB, `category_translations` <1 MB) are small enough that a rebuild is a
|
||||
matter of seconds — they keep their indexes on purpose.
|
||||
|
||||
Also relevant:
|
||||
|
||||
- `DB_LOCK_WAIT_TIMEOUT` defaults to 10 s so a blocked write frees its worker
|
||||
instead of piling up. A migration that legitimately has to wait needs
|
||||
`DB_LOCK_WAIT_TIMEOUT=300 php artisan migrate --force`.
|
||||
- Never add a write to a large table on a hot request path. The public view
|
||||
counter deliberately writes only to `press_release_events` and is summed up
|
||||
by the `press-releases:aggregate-views` scheduler task.
|
||||
- `php artisan db:online-alter` wraps `pt-online-schema-change` as an optional
|
||||
escape hatch if one of the small FULLTEXT tables ever grows large. It needs
|
||||
`percona-toolkit` on the server, which is deliberately **not** part of the
|
||||
standard setup.
|
||||
|
||||
### Testing Framework
|
||||
|
||||
- **Pest PHP** (v3.8+) - Modern testing framework built on PHPUnit
|
||||
|
|
|
|||
219
app/Console/Commands/OnlineSchemaChange.php
Normal file
219
app/Console/Commands/OnlineSchemaChange.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Führt eine Schemaänderung mit `pt-online-schema-change` aus, statt mit einem
|
||||
* blockierenden `ALTER TABLE`.
|
||||
*
|
||||
* Hintergrund: `press_releases` und `companies` tragen FULLTEXT-Indizes. Die
|
||||
* zwingen MySQL bei jedem `ADD COLUMN` auf `ALGORITHM=COPY` — ein vollständiger
|
||||
* Rebuild, währenddessen KEIN Schreibzugriff auf die Tabelle möglich ist (auf
|
||||
* dem Bestand über eine Stunde). pt-osc legt stattdessen eine Schattentabelle
|
||||
* an, hält sie per Trigger synchron, kopiert in Blöcken und tauscht am Ende
|
||||
* atomar um — die Originaltabelle bleibt durchgehend beschreibbar.
|
||||
*
|
||||
* Ablauf für eine Migration auf diesen Tabellen:
|
||||
*
|
||||
* 1. php artisan db:online-alter press_releases "ADD COLUMN foo TINYINT(1) NULL"
|
||||
* (ohne --execute nur Trockenlauf)
|
||||
* 2. … mit --execute wiederholen
|
||||
* 3. php artisan migrate --force
|
||||
*
|
||||
* Schritt 3 verlangt, dass die Migration idempotent geschrieben ist
|
||||
* (`Schema::hasColumn()`-Prüfung): Die Spalte existiert dann bereits, die
|
||||
* Migration wird nur noch als ausgeführt vermerkt.
|
||||
*/
|
||||
class OnlineSchemaChange extends Command
|
||||
{
|
||||
protected $signature = 'db:online-alter
|
||||
{table : Tabelle, z. B. press_releases}
|
||||
{alter : ALTER-Klausel OHNE "ALTER TABLE <name>", 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('<comment>'.$binary.' '.implode(' ', array_map(escapeshellarg(...), $arguments)).'</comment>');
|
||||
|
||||
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: <info>sudo apt-get install percona-toolkit</info>');
|
||||
|
||||
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<string, mixed> $config
|
||||
* @return list<string>
|
||||
*/
|
||||
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 `_<name>_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<string, mixed> $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');
|
||||
}
|
||||
}
|
||||
95
app/Console/Commands/ReindexPressReleaseSearch.php
Normal file
95
app/Console/Commands/ReindexPressReleaseSearch.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseSearchIndex;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Befüllt und repariert die Suchtabelle `press_release_search`.
|
||||
*
|
||||
* Zwei Einsätze:
|
||||
* - Einmalig beim Umstieg, um den Bestand zu indizieren. Läuft blockfrei:
|
||||
* Es wird nur aus `press_releases` gelesen und in die Suchtabelle
|
||||
* geschrieben, die zu diesem Zeitpunkt noch niemand abfragt.
|
||||
* - Später als Reparatur, falls doch einmal an Eloquent vorbei geschrieben
|
||||
* wurde (Massen-Updates per Query-Builder lösen keine Model-Events aus).
|
||||
*
|
||||
* Standardmäßig werden nur fehlende Zeilen ergänzt; `--all` schreibt alles neu.
|
||||
*
|
||||
* Zur Blockgröße: Ein Durchgang schreibt `--chunk` vollständige Textkörper in
|
||||
* EINEM `upsert`. Bei langen Meldungen kann das Statement `max_allowed_packet`
|
||||
* reißen (Standard 64 MB) — der Lauf bricht dann mit „Packet too large" ab.
|
||||
* Beim erstmaligen Indizieren des Bestands deshalb im Zweifel kleiner fahren:
|
||||
* `php artisan press-releases:reindex-search --chunk=100`.
|
||||
*/
|
||||
class ReindexPressReleaseSearch extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:reindex-search
|
||||
{--all : Alle Pressemitteilungen neu indizieren statt nur die fehlenden}
|
||||
{--chunk=500 : Pressemitteilungen je Durchgang}';
|
||||
|
||||
protected $description = 'Befüllt bzw. repariert die Volltext-Suchtabelle der Pressemitteilungen.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(50, (int) $this->option('chunk'));
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
$query = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->withTrashed()
|
||||
->select(['id', 'title', 'subtitle', 'text', 'keywords'])
|
||||
->when(! $all, fn (Builder $q) => $q->whereNotExists(
|
||||
fn ($sub) => $sub->select(DB::raw(1))
|
||||
->from('press_release_search')
|
||||
->whereColumn('press_release_search.press_release_id', 'press_releases.id'),
|
||||
));
|
||||
|
||||
$total = (clone $query)->toBase()->getCountForPagination();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('Suchindex ist vollständig — nichts zu tun.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(($all ? 'Neu indiziert' : 'Ergänzt').": {$total} Pressemitteilungen.");
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
|
||||
$done = 0;
|
||||
|
||||
// chunkById statt chunk(): Beim Ergänzen verschwinden die verarbeiteten
|
||||
// Zeilen aus der Ergebnismenge, ein Offset würde dann Einträge
|
||||
// überspringen.
|
||||
$query->chunkById($chunk, function ($releases) use (&$done, $bar): void {
|
||||
$now = now();
|
||||
|
||||
PressReleaseSearchIndex::query()->upsert(
|
||||
$releases->map(fn (PressRelease $release): array => [
|
||||
'press_release_id' => $release->getKey(),
|
||||
'title' => (string) $release->title,
|
||||
'subtitle' => $release->subtitle,
|
||||
'text' => (string) $release->text,
|
||||
'keywords' => $release->keywords,
|
||||
'indexed_at' => $now,
|
||||
])->all(),
|
||||
['press_release_id'],
|
||||
['title', 'subtitle', 'text', 'keywords', 'indexed_at'],
|
||||
);
|
||||
|
||||
$done += $releases->count();
|
||||
$bar->advance($releases->count());
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("Fertig: {$done} Einträge geschrieben.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -536,10 +536,14 @@ class PressRelease extends Model
|
|||
return $query->where(function (Builder $outer) use ($tokens, $useFullText, $companyIds, $categoryIds): void {
|
||||
$outer->where(function (Builder $content) use ($tokens, $useFullText): void {
|
||||
if ($useFullText) {
|
||||
$content->whereFullText(
|
||||
// Der Volltext-Index liegt auf der Suchtabelle, nicht auf
|
||||
// press_releases — sonst wäre dort jedes ADD COLUMN ein
|
||||
// stundenlanger Rebuild (siehe PressReleaseSearchIndex).
|
||||
PressReleaseSearchIndex::constrain(
|
||||
$content,
|
||||
['title', 'subtitle', 'text', 'keywords'],
|
||||
$tokens->map(fn (string $token): string => '+'.$token.'*')->implode(' '),
|
||||
['mode' => 'boolean']
|
||||
['mode' => 'boolean'],
|
||||
);
|
||||
|
||||
return;
|
||||
|
|
|
|||
170
app/Models/PressReleaseSearchIndex.php
Normal file
170
app/Models/PressReleaseSearchIndex.php
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Suchindex-Zeile einer Pressemitteilung — eine 1:1-Kopie der durchsuchbaren
|
||||
* Felder, damit die Volltext-Indizes nicht auf `press_releases` liegen müssen
|
||||
* (siehe Migration `create_press_release_search_table`).
|
||||
*
|
||||
* Diese Tabelle wird bewusst NIE um Spalten erweitert: Sie trägt die
|
||||
* FULLTEXT-Indizes, und genau die machen ein `ADD COLUMN` teuer. Neue Felder
|
||||
* gehören nach `press_releases`, wo sie dank fehlendem Volltext-Index in
|
||||
* Millisekunden angelegt sind.
|
||||
*/
|
||||
class PressReleaseSearchIndex extends Model
|
||||
{
|
||||
protected $table = 'press_release_search';
|
||||
|
||||
protected $primaryKey = 'press_release_id';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'press_release_id',
|
||||
'title',
|
||||
'subtitle',
|
||||
'text',
|
||||
'keywords',
|
||||
'indexed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Spalten, die den Index aktuell halten. Ändert sich keine davon, muss
|
||||
* auch nicht neu indiziert werden — der Textkörper allein macht mehrere
|
||||
* hundert Megabyte aus.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SourceColumns = ['title', 'subtitle', 'text', 'keywords'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['indexed_at' => 'datetime'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<PressRelease, $this>
|
||||
*/
|
||||
public function pressRelease(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressRelease::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-Key der Umschalt-Prüfung (siehe isAvailable()).
|
||||
*/
|
||||
public const AvailabilityCacheKey = 'press-release-search.available';
|
||||
|
||||
/**
|
||||
* Ist die Suchtabelle die maßgebliche Quelle?
|
||||
*
|
||||
* Der Umstieg läuft über mehrere Schritte (Tabelle anlegen → Bestand
|
||||
* indizieren → alte Indizes entfernen). Maßgeblich ist deshalb NICHT, ob
|
||||
* die Tabelle existiert — zwischen Anlegen und fertigem Reindex ist sie
|
||||
* leer, die Suche fände dann nichts. Umgeschaltet wird erst, wenn
|
||||
* `press_releases` seine Volltext-Indizes verloren hat: Genau dieser
|
||||
* Schritt setzt einen vollständigen Suchindex voraus und wird von der
|
||||
* Migration erzwungen. Bis dahin sucht die Anwendung unverändert weiter.
|
||||
*
|
||||
* Die beiden Antworten werden BEWUSST unterschiedlich lange gecacht:
|
||||
* `true` ist der Dauerzustand nach dem Umstieg. `false` gilt nur bis zum
|
||||
* Entfernen der Indizes und schickt die Suche so lange auf den
|
||||
* Rückfallpfad — greift der noch, NACHDEM die Migration die Indizes
|
||||
* entfernt hat, quittiert MySQL das mit Fehler 1191 („Can't find FULLTEXT
|
||||
* index matching the column list") und jede Suche endet im 500er. Die
|
||||
* Migrationen räumen den Eintrag deshalb zusätzlich selbst ab; die kurze
|
||||
* TTL ist das Netz für den Fall, dass jemand die Indizes von Hand entfernt.
|
||||
*/
|
||||
public static function isAvailable(): bool
|
||||
{
|
||||
$cached = Cache::get(self::AvailabilityCacheKey);
|
||||
|
||||
if ($cached !== null) {
|
||||
return (bool) $cached;
|
||||
}
|
||||
|
||||
$available = self::resolveAvailability();
|
||||
|
||||
Cache::put(self::AvailabilityCacheKey, $available, $available ? 60 : 5);
|
||||
|
||||
return $available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft die zwischengespeicherte Umschalt-Prüfung. Gehört in jede
|
||||
* Migration, die an den Volltext-Indizes oder an der Suchtabelle dreht.
|
||||
*/
|
||||
public static function forgetAvailability(): void
|
||||
{
|
||||
Cache::forget(self::AvailabilityCacheKey);
|
||||
}
|
||||
|
||||
private static function resolveAvailability(): bool
|
||||
{
|
||||
if (! Schema::hasTable('press_release_search')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Schema::getConnection()->getDriverName() !== 'mysql') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return DB::select("SHOW INDEX FROM press_releases WHERE Index_type = 'FULLTEXT'") === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schränkt eine Abfrage auf `press_releases` per Volltextsuche ein.
|
||||
*
|
||||
* Bewusst als unkorrelierte Unterabfrage (`whereIn`) formuliert: So ist die
|
||||
* Suchtabelle die treibende Tabelle und MySQL wertet den Volltext-Index
|
||||
* genau einmal aus. Ein korreliertes `whereExists` würde ihn pro Zeile der
|
||||
* Ergebnismenge auswerten.
|
||||
*
|
||||
* @param list<string> $columns MUSS exakt einer Index-Definition entsprechen
|
||||
*/
|
||||
public static function constrain(QueryBuilder|EloquentBuilder $query, array $columns, string $expression, array $options = []): void
|
||||
{
|
||||
if (! self::isAvailable()) {
|
||||
// Umstieg noch nicht abgeschlossen — press_releases trägt die
|
||||
// Indizes noch selbst. Die Suche bleibt dadurch während des
|
||||
// gesamten Deploys unverändert brauchbar.
|
||||
$query->whereFullText($columns, $expression, $options);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereIn('press_releases.id', function (QueryBuilder $sub) use ($columns, $expression, $options): void {
|
||||
$sub->select('press_release_id')
|
||||
->from('press_release_search')
|
||||
->whereFullText($columns, $expression, $options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt (oder aktualisiert) die Index-Zeile einer Pressemitteilung.
|
||||
*/
|
||||
public static function syncFor(PressRelease $pressRelease): void
|
||||
{
|
||||
static::query()->updateOrCreate(
|
||||
['press_release_id' => $pressRelease->getKey()],
|
||||
[
|
||||
'title' => (string) $pressRelease->title,
|
||||
'subtitle' => $pressRelease->subtitle,
|
||||
'text' => (string) $pressRelease->text,
|
||||
'keywords' => $pressRelease->keywords,
|
||||
'indexed_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/Observers/PressReleaseSearchObserver.php
Normal file
53
app/Observers/PressReleaseSearchObserver.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseSearchIndex;
|
||||
|
||||
/**
|
||||
* Hält die Suchtabelle synchron zur Pressemitteilung.
|
||||
*
|
||||
* Der Importer und die Anwendung schreiben durchgängig über Eloquent
|
||||
* (`updateOrCreate`, `->save()`), deshalb greift der Observer auch bei
|
||||
* Importen. Wer die Tabelle künftig per Query-Builder in Massen ändert, muss
|
||||
* anschließend `php artisan press-releases:reindex-search` laufen lassen —
|
||||
* Model-Events greifen dort nicht.
|
||||
*/
|
||||
class PressReleaseSearchObserver
|
||||
{
|
||||
public function created(PressRelease $pressRelease): void
|
||||
{
|
||||
PressReleaseSearchIndex::syncFor($pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur neu indizieren, wenn sich wirklich ein durchsuchbares Feld geändert
|
||||
* hat — der Textkörper macht den Großteil der Datenmenge aus, ihn bei
|
||||
* jedem Statuswechsel neu zu schreiben wäre reine Verschwendung.
|
||||
*
|
||||
* Bewusst `updated` statt `saved` mit einer `wasRecentlyCreated`-Abfrage:
|
||||
* Dieses Kennzeichen bleibt für die Lebensdauer der Instanz gesetzt, ein
|
||||
* späteres Update auf demselben Objekt hätte den Index also trotzdem
|
||||
* jedes Mal neu geschrieben.
|
||||
*/
|
||||
public function updated(PressRelease $pressRelease): void
|
||||
{
|
||||
if (! $pressRelease->wasChanged(PressReleaseSearchIndex::SourceColumns)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PressReleaseSearchIndex::syncFor($pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hartes Löschen räumt der Fremdschlüssel (cascadeOnDelete) mit ab; ein
|
||||
* Soft Delete lässt die Index-Zeile bewusst stehen, weil die Suche
|
||||
* ohnehin über `press_releases` filtert und gelöschte PMs dort schon
|
||||
* ausgeschlossen sind.
|
||||
*/
|
||||
public function forceDeleted(PressRelease $pressRelease): void
|
||||
{
|
||||
PressReleaseSearchIndex::query()->whereKey($pressRelease->getKey())->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ use App\Models\User;
|
|||
use App\Models\WithdrawalRequest;
|
||||
use App\Observers\ActivityLogObserver;
|
||||
use App\Observers\AdminPerformanceCacheObserver;
|
||||
use App\Observers\PressReleaseSearchObserver;
|
||||
use App\Services\Admin\AdminRequestPerformanceMetrics;
|
||||
use App\Services\Newsletter\NullNewsletterSyncClient;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
|
|
@ -177,6 +178,10 @@ class AppServiceProvider extends ServiceProvider
|
|||
TerminationRequest::observe(ActivityLogObserver::class);
|
||||
NewsletterSubscription::observe(ActivityLogObserver::class);
|
||||
|
||||
// Hält die Suchtabelle synchron — sie trägt die Volltext-Indizes,
|
||||
// damit press_releases ohne teuren Rebuild erweitert werden kann.
|
||||
PressRelease::observe(PressReleaseSearchObserver::class);
|
||||
|
||||
AdminPreset::observe(AdminPerformanceCacheObserver::class);
|
||||
Category::observe(AdminPerformanceCacheObserver::class);
|
||||
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,30 @@
|
|||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/*
|
||||
* Wartezeit auf Tabellen-Sperren (Metadata Lock).
|
||||
*
|
||||
* MySQL wartet per Default 31536000 Sekunden — ein Jahr. Läuft eine
|
||||
* Schemaänderung (auf press_releases/companies erzwingen die FULLTEXT-Indizes
|
||||
* einen vollständigen COPY-Rebuild), blockiert jeder Schreibzugriff so lange
|
||||
* und belegt dabei einen PHP-FPM-Worker samt DB-Verbindung. Sind die
|
||||
* Verbindungen aufgebraucht, steht die gesamte Anwendung — auch Sessions,
|
||||
* Cache und Queue, die hier ebenfalls in MySQL liegen.
|
||||
*
|
||||
* Requests scheitern deshalb nach wenigen Sekunden, statt sich aufzustauen.
|
||||
* Begrenzt wird nur das WARTEN auf die Sperre — eine bereits laufende
|
||||
* Migration wird davon nicht abgeschnitten.
|
||||
*
|
||||
* Bewusst ein einziger Wert aus der Umgebung statt einer Fallunterscheidung
|
||||
* über PHP_SAPI: `config:cache` friert den zur Cache-Zeit (CLI!) ermittelten
|
||||
* Wert ein, eine solche Weiche würde also im Web still den falschen Wert
|
||||
* liefern. Braucht ein Lauf mehr Geduld — etwa eine Migration auf einer
|
||||
* großen Tabelle —, wird er explizit hochgesetzt:
|
||||
*
|
||||
* DB_LOCK_WAIT_TIMEOUT=300 php artisan migrate --force
|
||||
*/
|
||||
$lockWaitTimeout = 'SET SESSION lock_wait_timeout = '.(int) env('DB_LOCK_WAIT_TIMEOUT', 10);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|
|
@ -59,6 +83,7 @@ return [
|
|||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => $lockWaitTimeout,
|
||||
]) : [],
|
||||
],
|
||||
'mysql_presseecho' => [
|
||||
|
|
@ -78,6 +103,7 @@ return [
|
|||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => $lockWaitTimeout,
|
||||
]) : [],
|
||||
],
|
||||
'mysql_businessportal' => [
|
||||
|
|
@ -97,6 +123,7 @@ return [
|
|||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => $lockWaitTimeout,
|
||||
]) : [],
|
||||
],
|
||||
'mariadb' => [
|
||||
|
|
@ -116,6 +143,7 @@ return [
|
|||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => $lockWaitTimeout,
|
||||
]) : [],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PressReleaseSearchIndex;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Suchtabelle für Pressemitteilungen — hält die Volltext-Indizes von
|
||||
* `press_releases` fern.
|
||||
*
|
||||
* Grund: Ein FULLTEXT-Index zwingt MySQL bei JEDEM `ADD COLUMN` auf
|
||||
* `ALGORITHM=COPY`, also einen vollständigen Rebuild der Tabelle, währenddessen
|
||||
* kein Schreibzugriff möglich ist. Bei `press_releases` (über 1 GB) dauert das
|
||||
* je nach Datenträger bis zu einer Stunde. Ohne Volltext-Index kostet dieselbe
|
||||
* Spalte rund 45 Millisekunden (`ALGORITHM=INSTANT`).
|
||||
*
|
||||
* Die Suchtabelle trägt die Indizes stattdessen — ihr Schema ist per
|
||||
* Konstruktion stabil, sie wird nie erweitert. Der Inhalt ist bewusst eine
|
||||
* Kopie: Dadurch bleibt der gesamte übrige Code unverändert, nur die beiden
|
||||
* Suchabfragen greifen woanders hin. Synchron hält sie der Observer auf
|
||||
* `PressRelease`; reparieren lässt sie sich mit
|
||||
* `php artisan press-releases:reindex-search`.
|
||||
*
|
||||
* Die Spaltentypen entsprechen exakt denen in `press_releases`, damit die
|
||||
* Treffer identisch ausfallen.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('press_release_search', function (Blueprint $table) {
|
||||
$table->foreignId('press_release_id')->primary()->constrained()->cascadeOnDelete();
|
||||
$table->string('title');
|
||||
$table->string('subtitle')->nullable();
|
||||
$table->mediumText('text');
|
||||
$table->string('keywords')->nullable();
|
||||
$table->timestamp('indexed_at')->nullable();
|
||||
});
|
||||
|
||||
// Die beiden Index-Definitionen sind 1:1 von press_releases übernommen.
|
||||
// MySQL verlangt, dass die Spaltenliste in MATCH() exakt einer
|
||||
// Index-Definition entspricht — jede Abweichung würde den Index
|
||||
// stillschweigend ungenutzt lassen.
|
||||
if (Schema::getConnection()->getDriverName() === 'mysql') {
|
||||
Schema::table('press_release_search', function (Blueprint $table) {
|
||||
$table->fullText(['title', 'subtitle', 'text', 'keywords'], 'press_release_search_web_fulltext');
|
||||
$table->fullText(['title', 'keywords'], 'press_release_search_admin_fulltext');
|
||||
});
|
||||
}
|
||||
|
||||
// Die Umschalt-Prüfung ist gecacht — ohne das Verwerfen liefe die
|
||||
// Suche bis zum Ablauf des Eintrags auf einem veralteten Stand.
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('press_release_search');
|
||||
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PressReleaseSearchIndex;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Entfernt die beiden Volltext-Indizes von `press_releases` — ab hier kostet
|
||||
* eine neue Spalte dort Millisekunden statt einer Stunde.
|
||||
*
|
||||
* Die Suche läuft ab sofort über `press_release_search`. Diese Migration
|
||||
* verweigert deshalb den Dienst, solange der Suchindex nicht vollständig ist:
|
||||
* Sonst würde die Volltextsuche stillschweigend Treffer verlieren.
|
||||
*
|
||||
* Wichtig für den nächsten Schritt: Das Entfernen der Indizes allein reicht
|
||||
* NICHT, damit MySQL wieder `ALGORITHM=INSTANT` erlaubt. InnoDB lässt eine
|
||||
* versteckte `FTS_DOC_ID`-Spalte zurück; erst der nächste vollständige Rebuild
|
||||
* räumt sie weg. Die noch ausstehende `gone_at`-Migration erledigt das
|
||||
* nebenbei — danach sind Spaltenänderungen dauerhaft instantan.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::getConnection()->getDriverName() !== 'mysql') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertSearchIndexIsComplete();
|
||||
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->dropFullText('press_releases_web_search_fulltext');
|
||||
$table->dropFullText('press_releases_admin_search_fulltext');
|
||||
});
|
||||
|
||||
// ZWINGEND: Die Umschalt-Prüfung (PressReleaseSearchIndex::isAvailable)
|
||||
// ist gecacht. Steht dort noch `false`, setzt der Rückfallpfad sein
|
||||
// MATCH() weiter auf `press_releases` ab — wo der Index soeben
|
||||
// verschwunden ist. MySQL antwortet mit Fehler 1191 und jede Suche
|
||||
// endet im 500er, bis der Eintrag abläuft.
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::getConnection()->getDriverName() !== 'mysql') {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->fullText(['title', 'subtitle', 'text', 'keywords'], 'press_releases_web_search_fulltext');
|
||||
$table->fullText(['title', 'keywords'], 'press_releases_admin_search_fulltext');
|
||||
});
|
||||
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
}
|
||||
|
||||
private function assertSearchIndexIsComplete(): void
|
||||
{
|
||||
$missing = DB::table('press_releases')
|
||||
->whereNotExists(fn ($sub) => $sub->select(DB::raw(1))
|
||||
->from('press_release_search')
|
||||
->whereColumn('press_release_search.press_release_id', 'press_releases.id'))
|
||||
->count();
|
||||
|
||||
if ($missing > 0) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Suchindex unvollständig: %d Pressemitteilungen fehlen. '
|
||||
.'Erst "php artisan press-releases:reindex-search" laufen lassen, dann erneut migrieren.',
|
||||
$missing,
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ use App\Models\Company;
|
|||
use App\Models\Contact;
|
||||
use App\Models\KiAudit;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseSearchIndex;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
|
|
@ -292,7 +293,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
|
|||
$term = trim($this->search);
|
||||
$q->where(function ($q) use ($term): void {
|
||||
if ($this->supportsFullTextSearch($term)) {
|
||||
$q->whereFullText(['title', 'keywords'], $term)
|
||||
// Volltext über die Suchtabelle — press_releases selbst
|
||||
// trägt keinen Index mehr, damit neue Spalten dort ohne
|
||||
// stundenlangen Rebuild angelegt werden können.
|
||||
$q->where(fn ($inner) => PressReleaseSearchIndex::constrain($inner, ['title', 'keywords'], $term))
|
||||
->orWhereHas('company', fn ($q) => $q->whereFullText(['name', 'email', 'slug'], $term));
|
||||
|
||||
return;
|
||||
|
|
|
|||
117
tests/Feature/OnlineSchemaChangeCommandTest.php
Normal file
117
tests/Feature/OnlineSchemaChangeCommandTest.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?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();
|
||||
});
|
||||
197
tests/Feature/PressReleaseSearchIndexTest.php
Normal file
197
tests/Feature/PressReleaseSearchIndexTest.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseSearchIndex;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Die Suchtabelle hält die Volltext-Indizes von `press_releases` fern, damit
|
||||
* dort neue Spalten ohne stundenlangen Rebuild angelegt werden können. Damit
|
||||
* das trägt, muss die Kopie zuverlässig synchron bleiben.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
});
|
||||
|
||||
test('creating a press release indexes it right away', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create([
|
||||
'title' => 'Solarpark eröffnet',
|
||||
'text' => 'Ein ausführlicher Text über den neuen Solarpark.',
|
||||
'keywords' => 'solar, energie',
|
||||
]);
|
||||
|
||||
$index = PressReleaseSearchIndex::query()->find($release->id);
|
||||
|
||||
expect($index)->not->toBeNull()
|
||||
->and($index->title)->toBe('Solarpark eröffnet')
|
||||
->and($index->text)->toBe('Ein ausführlicher Text über den neuen Solarpark.')
|
||||
->and($index->keywords)->toBe('solar, energie')
|
||||
->and($index->indexed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('editing a searchable field updates the index', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create(['title' => 'Alter Titel']);
|
||||
|
||||
$release->update(['title' => 'Neuer Titel', 'text' => 'Neuer Inhalt.']);
|
||||
|
||||
$index = PressReleaseSearchIndex::query()->find($release->id);
|
||||
|
||||
expect($index->title)->toBe('Neuer Titel')
|
||||
->and($index->text)->toBe('Neuer Inhalt.');
|
||||
});
|
||||
|
||||
test('changing an unrelated field does not rewrite the index', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create();
|
||||
$indexedAt = PressReleaseSearchIndex::query()->find($release->id)->indexed_at;
|
||||
|
||||
$this->travel(2)->minutes();
|
||||
$release->update(['hits' => 999]);
|
||||
|
||||
// Der Textkörper macht den Großteil der Datenmenge aus — ihn bei jedem
|
||||
// Statuswechsel neu zu schreiben wäre reine Verschwendung.
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id)->indexed_at->timestamp)
|
||||
->toBe($indexedAt->timestamp);
|
||||
});
|
||||
|
||||
test('force deleting a press release removes its index row', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create();
|
||||
|
||||
$release->forceDelete();
|
||||
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('a soft deleted press release keeps its index row', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create();
|
||||
|
||||
$release->delete();
|
||||
|
||||
// Die Suche filtert ohnehin über press_releases; dort ist die gelöschte PM
|
||||
// durch den SoftDeletes-Scope bereits ausgeschlossen.
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('the reindex command fills in rows that were written past eloquent', function () {
|
||||
/** @var TestCase $this */
|
||||
$releases = PressRelease::factory()->count(3)->create();
|
||||
|
||||
// Massen-Update per Query-Builder löst keine Model-Events aus — genau der
|
||||
// Fall, für den der Reparatur-Lauf existiert.
|
||||
PressReleaseSearchIndex::query()->delete();
|
||||
expect(PressReleaseSearchIndex::query()->count())->toBe(0);
|
||||
|
||||
$this->artisan('press-releases:reindex-search')->assertSuccessful();
|
||||
|
||||
expect(PressReleaseSearchIndex::query()->count())->toBe(3)
|
||||
->and(PressReleaseSearchIndex::query()->find($releases->first()->id)->title)
|
||||
->toBe($releases->first()->title);
|
||||
});
|
||||
|
||||
test('the reindex command repairs stale content with --all', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = PressRelease::factory()->create(['title' => 'Richtiger Titel']);
|
||||
|
||||
PressReleaseSearchIndex::query()->whereKey($release->id)->update(['title' => 'Veraltet']);
|
||||
|
||||
// Ohne --all bleibt eine vorhandene Zeile unangetastet …
|
||||
$this->artisan('press-releases:reindex-search')->assertSuccessful();
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id)->title)->toBe('Veraltet');
|
||||
|
||||
// … erst der vollständige Lauf schreibt sie neu.
|
||||
$this->artisan('press-releases:reindex-search', ['--all' => true])->assertSuccessful();
|
||||
expect(PressReleaseSearchIndex::query()->find($release->id)->title)->toBe('Richtiger Titel');
|
||||
});
|
||||
|
||||
test('the fulltext constraint drives off the search table', function () {
|
||||
/** @var TestCase $this */
|
||||
$query = PressRelease::query();
|
||||
PressReleaseSearchIndex::constrain($query, ['title', 'keywords'], 'solar');
|
||||
|
||||
// Auf der Builder-Struktur geprüft statt auf dem fertigen SQL: SQLite
|
||||
// kann Volltext gar nicht übersetzen, die Form der Abfrage ist aber
|
||||
// treiberunabhängig — und genau sie entscheidet über die Laufzeit.
|
||||
$where = $query->getQuery()->wheres[0];
|
||||
|
||||
expect($where['type'])->toBe('InSub')
|
||||
// Die Suchtabelle ist die treibende Tabelle; der Volltext-Index wird
|
||||
// einmal ausgewertet, nicht pro Zeile der Ergebnismenge.
|
||||
->and($where['column'])->toBe('press_releases.id')
|
||||
->and($where['query']->from)->toBe('press_release_search')
|
||||
->and(collect($where['query']->wheres)->pluck('type')->all())->toContain('Fulltext')
|
||||
// Unkorreliert: keine Verknüpfung auf die äußere Abfrage.
|
||||
->and(collect($where['query']->wheres)->pluck('type')->all())->not->toContain('Column');
|
||||
})->skip(
|
||||
fn (): bool => DB::connection()->getDriverName() !== 'mysql',
|
||||
'Nur auf MySQL: whereIn() übersetzt die Unterabfrage sofort, und SQLite kennt keine Volltextsuche.',
|
||||
);
|
||||
|
||||
test('search falls back gracefully while the table is missing', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeTrue();
|
||||
|
||||
Schema::drop('press_release_search');
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
|
||||
// Zwischen Code-Deploy und Migration darf die Suche nicht mit einem
|
||||
// SQL-Fehler aussteigen, sondern weicht auf LIKE aus.
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeFalse();
|
||||
|
||||
$results = PressRelease::query()
|
||||
->withoutGlobalScopes()
|
||||
->matchingSearchTerm('solar', 'de')
|
||||
->get();
|
||||
|
||||
expect($results)->toBeCollection();
|
||||
});
|
||||
|
||||
test('a negative switch check is cached only briefly', function () {
|
||||
/** @var TestCase $this */
|
||||
Schema::drop('press_release_search');
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeFalse()
|
||||
->and(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeFalse();
|
||||
|
||||
// `false` gilt nur während des Umstiegs und schickt die Suche so lange auf
|
||||
// den Rückfallpfad. Steht der Eintrag noch, NACHDEM die Migration die
|
||||
// Volltext-Indizes von press_releases entfernt hat, setzt der Rückfallpfad
|
||||
// sein MATCH() auf einen Index ab, den es nicht mehr gibt — MySQL antwortet
|
||||
// mit Fehler 1191 und jede Suche endet im 500er.
|
||||
$this->travel(10)->seconds();
|
||||
|
||||
expect(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeNull();
|
||||
});
|
||||
|
||||
test('a positive switch check is cached for a minute', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeTrue();
|
||||
|
||||
// Der Dauerzustand nach dem Umstieg — hier wäre ein SHOW INDEX pro Suche
|
||||
// reine Verschwendung.
|
||||
$this->travel(30)->seconds();
|
||||
|
||||
expect(Cache::get(PressReleaseSearchIndex::AvailabilityCacheKey))->toBeTrue();
|
||||
});
|
||||
|
||||
test('forgetting the switch check re-evaluates it', function () {
|
||||
/** @var TestCase $this */
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeTrue();
|
||||
|
||||
Schema::drop('press_release_search');
|
||||
|
||||
// Ohne das Verwerfen bliebe die veraltete Antwort stehen — genau deshalb
|
||||
// rufen die beiden Such-Migrationen forgetAvailability() auf.
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeTrue();
|
||||
|
||||
PressReleaseSearchIndex::forgetAvailability();
|
||||
|
||||
expect(PressReleaseSearchIndex::isAvailable())->toBeFalse();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue