Fassung 1.4.5: drei Fehler, die nur auf fremden Servern auftraten
Alle drei von Umut auf seinem Hosting gefunden, keiner davon auf meinem Rechner sichtbar. - Der Updateknopf endete mit 500. Das Update lief vollstaendig durch - alle neun Schritte, Dateien getauscht, var/ und .env unberuehrt -, aber die Ergebnisseite starb daran: der Updater ueber Git nennt einen Schritt "step", der ueber das Archiv nannte ihn "label", beide legen ihn unter demselben Sitzungsschluessel ab, und die Vorlage liest "step". Damit war auch der zweite Knopf unerreichbar. Zehn Tests prueften das Rueckgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie (ADR 071). - Die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git. "git ist installiert" und "das hier ist eine Arbeitskopie" waren derselbe Schalter; auf dem Hosting ist Git vorhanden, die Installation kam aber aus dem Archiv. Dort stand "fatal: Not a git repository" an der Stelle der Fassungsnummer. Ohne Arbeitskopie verschwinden die Git-Karten jetzt (ADR 072). - "Sicherung anlegen" endete mit 500: VACUUM INTO gibt es erst seit SQLite 3.27, das Hosting hat eine aeltere Fassung - alt genug fuer die Installation, zu alt fuer die Sicherung. Bei aelteren wird jetzt unter einer Schreibsperre kopiert, und der Knopf faengt jeden Fehler ab statt mit einer 500-Seite zu enden (ADR 072). Nachgestellt wurde das mit einer echten 1.4.3-Installation aus dem Releasearchiv, eigener Datenbank, eigenem Port. Der Archiv-Updater laeuft dort vollstaendig durch: 1.4.3 -> 1.4.4, neun Schritte, var/ und .env unberuehrt. Geprueft: 433 Tests auf PHP 8.2 und 8.4, Editor-Browsertest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4153ba0f65
commit
d295757ce0
11 files changed
+227
-26
No files matched your search
@@ -91,13 +91,13 @@ final class ArchiveUpdate
|
||||
/**
|
||||
* Spielt die angegebene (oder die neueste) Fassung ein.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{label: string, ok: bool, detail: string}>}
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function run(?string $version = null): array
|
||||
{
|
||||
$schritte = [];
|
||||
$melden = static function (array &$schritte, string $label, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['label' => $label, 'ok' => $ok, 'detail' => $detail];
|
||||
$melden = static function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
|
||||
|
||||
return $ok;
|
||||
};
|
||||
@@ -254,25 +254,25 @@ final class ArchiveUpdate
|
||||
*
|
||||
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{label: string, ok: bool, detail: string}>}
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function finish(Installation $installation): array
|
||||
{
|
||||
$schritte = [];
|
||||
try {
|
||||
$namen = $installation->migrate();
|
||||
$schritte[] = ['label' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
|
||||
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
|
||||
} catch (\Throwable $fehler) {
|
||||
$schritte[] = ['label' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$schritte[] = ['label' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
|
||||
$this->updater->version(),
|
||||
)];
|
||||
|
||||
+67
-3
@@ -35,6 +35,14 @@ final class Backup
|
||||
private string $mediaDirectory,
|
||||
private Connection $db,
|
||||
private CommandRunner $runner,
|
||||
/**
|
||||
* Ab welcher SQLite-Fassung `VACUUM INTO` benutzt wird.
|
||||
*
|
||||
* Als Parameter und nicht als Konstante, damit der Weg fuer aeltere Fassungen im Test
|
||||
* wirklich laeuft. Ein Ersatzweg, der nur auf fremden Servern ausgefuehrt wird, ist ein
|
||||
* ungeprueftes Versprechen.
|
||||
*/
|
||||
private string $vacuumSince = '3.27.0',
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -214,9 +222,19 @@ final class Backup
|
||||
throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).');
|
||||
}
|
||||
$target = $directory.'/'.basename($file);
|
||||
// VACUUM INTO instead of copy(): a copy taken while something writes can be unusable, and
|
||||
// it would not say so until the day it is needed. The path is ours, not user input.
|
||||
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
|
||||
// VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann
|
||||
// unbrauchbar sein - und sie sagt das nicht, sondern erst an dem Tag, an dem sie gebraucht
|
||||
// wird. Der Pfad kommt von uns, nicht aus einer Eingabe.
|
||||
//
|
||||
// **Es gibt VACUUM INTO aber erst seit SQLite 3.27.** Auf Umuts Hosting ist die Fassung
|
||||
// aelter: Die Installation lief (dafuer reicht 3.16), die Sicherung endete mit 500. Auf
|
||||
// einer solchen Installation wird stattdessen unter einer Schreibsperre kopiert - das ist
|
||||
// der Weg, den SQLite selbst fuer diesen Fall nennt, und er ist ebenso konsistent.
|
||||
if (version_compare($this->sqliteVersion(), $this->vacuumSince, '>=')) {
|
||||
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
|
||||
} else {
|
||||
$this->copyUnderLock($file, $target);
|
||||
}
|
||||
if (!is_readable($target)) {
|
||||
throw new InvalidPage('Die Datenbank ließ sich nicht sichern.');
|
||||
}
|
||||
@@ -224,6 +242,52 @@ final class Backup
|
||||
return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
|
||||
}
|
||||
|
||||
/** Die Fassung von SQLite, oder '0' wenn das keine SQLite-Verbindung ist. */
|
||||
private function sqliteVersion(): string
|
||||
{
|
||||
try {
|
||||
return (string) $this->db->fetchOne('SELECT sqlite_version()');
|
||||
} catch (\Throwable) {
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine konsistente Kopie ohne VACUUM INTO.
|
||||
*
|
||||
* `BEGIN IMMEDIATE` haelt jeden anderen Schreibvorgang an, solange kopiert wird; der
|
||||
* Checkpoint davor schreibt zurueck, was noch im Write-Ahead-Log steht, damit die Kopie
|
||||
* vollstaendig ist und nicht die Haelfte der letzten Aenderungen fehlt.
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
private function copyUnderLock(string $file, string $target): void
|
||||
{
|
||||
try {
|
||||
$this->db->executeStatement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
} catch (\Throwable) {
|
||||
// Ohne WAL gibt es nichts zurueckzuschreiben - das ist kein Fehler.
|
||||
}
|
||||
|
||||
try {
|
||||
$this->db->executeStatement('BEGIN IMMEDIATE');
|
||||
} catch (\Throwable $fehler) {
|
||||
throw new InvalidPage('Die Datenbank ließ sich fuer die Sicherung nicht sperren: '.$fehler->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
if (!@copy($file, $target)) {
|
||||
throw new InvalidPage('Die Datenbankdatei ließ sich nicht kopieren. Schreibrechte auf var/backups pruefen.');
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
$this->db->executeStatement('COMMIT');
|
||||
} catch (\Throwable) {
|
||||
// Die Sperre faellt spaetestens mit der Verbindung.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{count: int, bytes: int} */
|
||||
private function copyMedia(string $target): array
|
||||
{
|
||||
|
||||
+12
-3
@@ -46,14 +46,23 @@ final class Updater
|
||||
public function state(): array
|
||||
{
|
||||
$git = $this->runner->available() && $this->runner->has('git', $this->root);
|
||||
$status = $git ? $this->git(['status', '--porcelain']) : null;
|
||||
// Zwei verschiedene Fragen, und die Verwechslung war ein echter Fehler: Ob der Befehl
|
||||
// `git` existiert, sagt nichts darueber, ob dieses Verzeichnis eine Arbeitskopie ist. Ein
|
||||
// Hosting hat oft beides - git installiert, aber die Installation kam aus dem
|
||||
// Releasearchiv. Dann lief jeder Aufruf ins Leere, und die Verwaltung zeigte dem
|
||||
// Betreiber „fatal: Not a git repository“ an einer Stelle, an der eine Fassungsnummer
|
||||
// stehen sollte.
|
||||
$arbeitskopie = $git && is_dir($this->root.'/.git');
|
||||
$status = $arbeitskopie ? $this->git(['status', '--porcelain']) : null;
|
||||
|
||||
return [
|
||||
'version' => $this->version(),
|
||||
'commit' => $git ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
|
||||
'describe' => $git ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
|
||||
'commit' => $arbeitskopie ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
|
||||
'describe' => $arbeitskopie ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
|
||||
'maintenance' => is_file($this->root.'/'.self::MAINTENANCE_FILE),
|
||||
'git' => $git,
|
||||
// Der Weg ueber Git steht nur einer Arbeitskopie offen.
|
||||
'worktree' => $arbeitskopie,
|
||||
'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']),
|
||||
'log' => $this->log(),
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user