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:
Kevin Adametz 2026-09-01 11:04:43 +00:00
parent e6eca9c85f
commit 8fc18c239b
14 changed files with 1076 additions and 5 deletions

View file

@ -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

View file

@ -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

View 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');
}
}

View 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;
}
}

View file

@ -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;

View 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(),
],
);
}
}

View 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();
}
}

View file

@ -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);

View file

@ -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,
]) : [],
],

View file

@ -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();
}
};

View file

@ -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,
));
}
}
};

View file

@ -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;

View 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();
});

View 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();
});