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 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 15:33:31 +02:00
parent c3c05ff9f8
commit e643769be2
2 changed files with 78 additions and 19 deletions

View File

@ -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 `<rz>-<nn>`
// 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');
});

View File

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