From f8728c5bca604cbf9c3cada3c2803ec8dc840535 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 1 Sep 2026 11:04:43 +0000 Subject: [PATCH] technik: Volltext-Indizes von press_releases in eine eigene Suchtabelle auslagern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 8 +- CLAUDE.md | 36 +++ app/Console/Commands/OnlineSchemaChange.php | 219 ++++++++++++++++++ .../Commands/ReindexPressReleaseSearch.php | 95 ++++++++ app/Models/PressRelease.php | 8 +- app/Models/PressReleaseSearchIndex.php | 170 ++++++++++++++ app/Observers/PressReleaseSearchObserver.php | 53 +++++ app/Providers/AppServiceProvider.php | 5 + config/database.php | 28 +++ ...0000_create_press_release_search_table.php | 63 +++++ ...p_fulltext_indexes_from_press_releases.php | 76 ++++++ .../admin/press-releases/index.blade.php | 6 +- .../Feature/OnlineSchemaChangeCommandTest.php | 117 ++++++++++ tests/Feature/PressReleaseSearchIndexTest.php | 197 ++++++++++++++++ 14 files changed, 1076 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/OnlineSchemaChange.php create mode 100644 app/Console/Commands/ReindexPressReleaseSearch.php create mode 100644 app/Models/PressReleaseSearchIndex.php create mode 100644 app/Observers/PressReleaseSearchObserver.php create mode 100644 database/migrations/2026_08_25_140000_create_press_release_search_table.php create mode 100644 database/migrations/2026_08_25_150000_drop_fulltext_indexes_from_press_releases.php create mode 100644 tests/Feature/OnlineSchemaChangeCommandTest.php create mode 100644 tests/Feature/PressReleaseSearchIndexTest.php diff --git a/.env.example b/.env.example index 18478a84..98aa0fb7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7819095e..42007cb7 100644 --- a/CLAUDE.md +++ b/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 diff --git a/app/Console/Commands/OnlineSchemaChange.php b/app/Console/Commands/OnlineSchemaChange.php new file mode 100644 index 00000000..eae7adfb --- /dev/null +++ b/app/Console/Commands/OnlineSchemaChange.php @@ -0,0 +1,219 @@ +", 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'); + } +} diff --git a/app/Console/Commands/ReindexPressReleaseSearch.php b/app/Console/Commands/ReindexPressReleaseSearch.php new file mode 100644 index 00000000..72c10e46 --- /dev/null +++ b/app/Console/Commands/ReindexPressReleaseSearch.php @@ -0,0 +1,95 @@ +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; + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 71013393..9be0d8ec 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -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; diff --git a/app/Models/PressReleaseSearchIndex.php b/app/Models/PressReleaseSearchIndex.php new file mode 100644 index 00000000..84907915 --- /dev/null +++ b/app/Models/PressReleaseSearchIndex.php @@ -0,0 +1,170 @@ + + */ + public const SourceColumns = ['title', 'subtitle', 'text', 'keywords']; + + protected function casts(): array + { + return ['indexed_at' => 'datetime']; + } + + /** + * @return BelongsTo + */ + 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 $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(), + ], + ); + } +} diff --git a/app/Observers/PressReleaseSearchObserver.php b/app/Observers/PressReleaseSearchObserver.php new file mode 100644 index 00000000..5dbd6f54 --- /dev/null +++ b/app/Observers/PressReleaseSearchObserver.php @@ -0,0 +1,53 @@ +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(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 65508399..fc4c694f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/config/database.php b/config/database.php index ce0ca7a6..d8290118 100644 --- a/config/database.php +++ b/config/database.php @@ -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, ]) : [], ], diff --git a/database/migrations/2026_08_25_140000_create_press_release_search_table.php b/database/migrations/2026_08_25_140000_create_press_release_search_table.php new file mode 100644 index 00000000..56f36160 --- /dev/null +++ b/database/migrations/2026_08_25_140000_create_press_release_search_table.php @@ -0,0 +1,63 @@ +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(); + } +}; diff --git a/database/migrations/2026_08_25_150000_drop_fulltext_indexes_from_press_releases.php b/database/migrations/2026_08_25_150000_drop_fulltext_indexes_from_press_releases.php new file mode 100644 index 00000000..eed27af8 --- /dev/null +++ b/database/migrations/2026_08_25_150000_drop_fulltext_indexes_from_press_releases.php @@ -0,0 +1,76 @@ +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, + )); + } + } +}; diff --git a/resources/views/livewire/admin/press-releases/index.blade.php b/resources/views/livewire/admin/press-releases/index.blade.php index b01c4c86..dea1d5d3 100644 --- a/resources/views/livewire/admin/press-releases/index.blade.php +++ b/resources/views/livewire/admin/press-releases/index.blade.php @@ -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; diff --git a/tests/Feature/OnlineSchemaChangeCommandTest.php b/tests/Feature/OnlineSchemaChangeCommandTest.php new file mode 100644 index 00000000..200ab5dc --- /dev/null +++ b/tests/Feature/OnlineSchemaChangeCommandTest.php @@ -0,0 +1,117 @@ + '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(); +}); diff --git a/tests/Feature/PressReleaseSearchIndexTest.php b/tests/Feature/PressReleaseSearchIndexTest.php new file mode 100644 index 00000000..b43110b3 --- /dev/null +++ b/tests/Feature/PressReleaseSearchIndexTest.php @@ -0,0 +1,197 @@ +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(); +});