presseportale/tests/Feature/PressReleaseSearchIndexTest.php
Kevin Adametz 8fc18c239b 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>
2026-09-01 11:04:43 +00:00

197 lines
7.8 KiB
PHP

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