From e643769be2ca2020a3e06a8dcf42e61d6158508e Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 15:33:31 +0200 Subject: [PATCH] Fix-Runde 3: Wiederholbarkeit ehrlich gemacht, Seeder-Zaehler darf nicht sinken Zwei Befunde, die die letzte Fix-Runde selbst eingefuehrt hat: - Der Kommentar ueber dem dns_name-Block behauptete, ein Fehlschlag am Riegel liesse next_host_number schon da, dns_name aber noch stehen - verkehrt herum, dropColumn laeuft VOR dem Riegel. Scheitert also ausgerechnet unique('name'), ist dns_name schon weg, und ein zweiter Anlauf starb an der Vorabpruefung mit "Unknown column 'dns_name'" statt an der Stelle, die der Kommentar nannte. $hadDnsName wird jetzt einmal ermittelt und bindet Vorabpruefung, Uebertragung und den Spalten-Block an dasselbe Urteil - faellt dns_name schon in einem frueheren Anlauf, prueft die Vorabpruefung direkt auf name statt auf den nicht mehr vorhandenen CASE-Ausdruck, und meldet Doppelte weiterhin sauber statt sie stillschweigend durchzulassen. Kommentar korrigiert; die verbleibende (sehr schmale) Grenze - gelingt unique('name'), scheitert nur noch die DELETE-Zeile danach - ehrlich als offen benannt statt verschwiegen oder ungeprueft behauptet zu sein. - DatabaseSeeder schrieb next_host_number=2 durch den Update-Teil von updateOrCreate und drehte damit den Zaehler bei jedem Re-Seed einer Installation zurueck, die laengst weiterzaehlte - genau die Wieder- verwendung, gegen die dieser Umbau gebaut wurde. Jetzt max(vorhanden, 2): frisch angelegt hebt es auf 2, bestand die Zeile schon und zaehlte hoeher, bleibt sie stehen. Beide Fixe gegen echtes MariaDB auf eigenen Wegwerf-Datenbanken geprueft (drei Migrationslaeufe fuer den ersten Befund, zwei Saatlaeufe fuer den zweiten), nicht auf der geteilten Entwicklungsdatenbank. Gezielte Testlaeufe (173 + 21 bestanden) statt der vollen Suite, wie vorgegeben. Bericht angehaengt an .superpowers/sdd/2026-08-01-hostname-vergabe/final-fix-report.md. Co-Authored-By: Claude Opus 5 --- ..._090000_clupilot_vergibt_die_hostnamen.php | 77 +++++++++++++++---- database/seeders/DatabaseSeeder.php | 20 +++-- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php b/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php index 70261e0..fb0bbd0 100644 --- a/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php +++ b/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php @@ -31,6 +31,16 @@ return new class extends Migration { public function up(): void { + // EINMAL geprüft, für den ganzen Lauf festgehalten. `dns_name` wird + // weiter unten an drei Stellen angefasst — gelesen (Vorabprüfung), + // geschrieben (Übertragung) und gelöscht (Spalten-Block) — und alle + // drei müssen auf demselben Stand urteilen. Ein zweiter Anlauf nach + // einem Fehlschlag, der die Spalte schon fallen ließ (der + // Spalten-Block unten lief durch, aber `unique('name')` scheiterte + // erst danach), darf nicht zwei der drei Stellen noch für vorhanden + // halten, während die dritte sie schon vermisst. + $hadDnsName = Schema::hasColumn('hosts', 'dns_name'); + // Vorabprüfung, bevor irgendetwas angefasst wird — auch vor der // Spalte weiter unten, die selbst schon DDL ist. `hosts.name` trug // noch nie einen eindeutigen Index (siehe create_hosts_table): ein @@ -62,8 +72,20 @@ return new class extends Migration // den diese Prüfung abfangen soll. GROUP BY lässt dieselbe Kollation // entscheiden, die später den Index baut, statt in PHP etwas // nachzubilden, das von der Spalte abdriften kann. - $duplicates = DB::table('hosts') - ->selectRaw("(CASE WHEN dns_name IS NOT NULL AND dns_name <> '' THEN dns_name ELSE name END) as future_name") + // + // Und auf `$hadDnsName` verzweigt: ist die Spalte schon gefallen (ein + // zweiter Anlauf), gibt es den CASE-Ausdruck oben nicht mehr zu + // bilden — `name` trägt dann bereits den vollständigen + // Übertragungsstand, und die Prüfung läuft direkt auf ihr. + $duplicatesQuery = DB::table('hosts'); + + if ($hadDnsName) { + $duplicatesQuery->selectRaw("(CASE WHEN dns_name IS NOT NULL AND dns_name <> '' THEN dns_name ELSE name END) as future_name"); + } else { + $duplicatesQuery->select('name as future_name'); + } + + $duplicates = $duplicatesQuery ->groupBy('future_name') ->havingRaw('COUNT(*) > 1') ->pluck('future_name'); @@ -94,10 +116,19 @@ return new class extends Migration // Onboarding, nie so weit gekommen) behalten ihren — sie tragen keine // Nummer, die sich übertragen ließe, und das Weiterzählen in // HostName::free() geht an ihnen vorbei. - DB::table('hosts') - ->whereNotNull('dns_name') - ->where('dns_name', '<>', '') - ->update(['name' => DB::raw('dns_name')]); + // + // Übersprungen, wenn `dns_name` laut `$hadDnsName` schon gefallen ist: + // dann lief diese Zeile in einem früheren Anlauf schon durch, `name` + // trägt den Übertragungsstand bereits, und ein erneuter Versuch + // stürbe hier an „Unknown column 'dns_name'" — aus demselben Grund, + // aus dem die Vorabprüfung oben verzweigt und der Spalten-Block + // weiter unten bedingt läuft. + if ($hadDnsName) { + DB::table('hosts') + ->whereNotNull('dns_name') + ->where('dns_name', '<>', '') + ->update(['name' => DB::raw('dns_name')]); + } // Wer nach der Übertragung oben immer noch nicht wie `-` // aussieht, hatte keinen `dns_name` zum Übertragen — der getippte @@ -164,13 +195,21 @@ return new class extends Migration // Getrennte Aufrufe: Index löschen, Spalte löschen und Index anlegen in // einem Blueprint bringt SQLite (Testlauf) durcheinander. // - // Beide hinter derselben `hasColumn`-Prüfung wie oben und aus - // demselben Grund: scheitert der Riegel weiter unten, oder fehlt - // einer Rücksicherung genau der `dns_name`-Index, den `dropUnique` - // gleich sucht, ist `next_host_number` schon da, `dns_name` aber - // noch — ein zweiter Versuch soll wieder bis hierher kommen, nicht - // vorher an der Spalte scheitern. - if (Schema::hasColumn('hosts', 'dns_name')) { + // Hinter demselben `$hadDnsName` wie Vorabprüfung und Übertragung + // oben — nicht mehr hinter einer eigenen Schema::hasColumn-Abfrage, + // damit alle drei Stellen garantiert dasselbe Urteil fällen. Eine + // frühere Fassung dieses Kommentars behauptete, ein Fehlschlag am + // Riegel weiter unten ließe `dns_name` hier noch stehen — das war + // verkehrt herum: `dropColumn` läuft VOR dem Riegel, nicht danach. + // Scheitert `unique('name')`, ist `dns_name` zu diesem Zeitpunkt + // längst weg, und ein zweiter Anlauf, der das nicht wüsste, stürbe an + // der Vorabprüfung oben statt hier. Mit `$hadDnsName` an einer + // einzigen Stelle für den ganzen Lauf entschieden, ist diese + // Reihenfolge egal geworden: Vorabprüfung, Übertragung und dieser + // Block überspringen ihren `dns_name`-Anteil gemeinsam, sobald die + // Spalte einmal gefallen ist — unabhängig davon, WESHALB der Anlauf, + // der sie fallen ließ, danach noch gescheitert ist. + if ($hadDnsName) { Schema::table('hosts', function (Blueprint $table) { $table->dropUnique(['dns_name']); }); @@ -185,6 +224,18 @@ return new class extends Migration // an vorhandenen DATEN scheitern kann statt nur am Schema. Alles, was // danach noch aussteht (die Zeile unten), ist reine Aufräumarbeit an // etwas, das nichts mehr referenziert — sie steht deshalb ZULETZT. + // + // HIER, nicht mehr am `dns_name`-Block, endet die Wiederholbarkeit + // von oben: Vorabprüfung, Übertragung und Spalten-Block sind jetzt + // alle an `$hadDnsName` gebunden und damit beliebig oft wiederholbar. + // Gelingt aber ausgerechnet dieser Aufruf, und scheitert erst die + // reine DELETE-Zeile gleich danach (kein DDL mehr, aber ein + // Verbindungsabbruch oder ein Sperr-Timeout bleiben möglich), findet + // ein erneuter Anlauf den Index schon vor und stirbt an „Duplicate + // key name 'hosts_name_unique'" statt einfach die DELETE-Zeile zu + // wiederholen. Unbehandelt gelassen: den Riegel selbst gegen sich + // selbst abzusichern ist nicht mehr die billige Ergänzung, die dieser + // Fix war, sondern eine eigene Prüfrunde. Schema::table('hosts', function (Blueprint $table) { $table->unique('name'); }); diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 327aa96..424d66a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -47,15 +47,23 @@ class DatabaseSeeder extends Seeder ], ); - // Datacenters — hosts + orders pick their code. next_host_number - // steht schon auf 2: die Demoflotte unten belegt die 01 in jedem - // Rechenzentrum selbst, und ein Seed-Lauf ist kein Onboarding, das - // HostName::claim durchläuft und den Zähler von selbst hochzöge. + // Datacenters — hosts + orders pick their code. foreach ([['fsn', 'Falkenstein', 'DE'], ['hel', 'Helsinki', 'FI']] as [$dcCode, $dcName, $dcLocation]) { - Datacenter::updateOrCreate( + $datacenter = Datacenter::updateOrCreate( ['code' => $dcCode], - ['name' => $dcName, 'location' => $dcLocation, 'active' => true, 'next_host_number' => 2], + ['name' => $dcName, 'location' => $dcLocation, 'active' => true], ); + + // next_host_number gehört NICHT ins zweite Argument von + // updateOrCreate(): das schriebe bei jedem erneuten Säen auf 2 + // zurück, auch auf einer Installation, die längst weiterzählt — + // genau die Wiederverwendung, gegen die dieser ganze Umbau + // gebaut wurde. max() hält stattdessen den höheren der beiden + // Werte: frisch angelegt steht die Spalte auf ihrem + // Schema-Default 1, max(1, 2) hebt sie auf 2 (die 01 ist unten + // durch die Demoflotte belegt); bestand die Zeile schon und + // zählte höher, bleibt sie stehen. + $datacenter->update(['next_host_number' => max($datacenter->next_host_number, 2)]); } // Demo fleet so the operator console hosts view has content locally.