Bisher wurde nur EINMAL gesichert: direkt vor einer Umstellung der
Datenbank. Faellt die Platte aus, ist alles weg -- die komplette
Betreuung, jede Aufgabe, jedes Protokoll. Das war das einzige Risiko im
System, das auf einen Schlag ALLES kostet.
Beim Nachsehen auf dem Server kam ein zweiter, schwerwiegenderer Befund
dazu:
workspace.db 4.096 B
workspace.db-wal 2.101.232 B
Im WAL-Betrieb landen Schreibvorgaenge zuerst im -wal und wandern erst
beim Checkpoint in die Hauptdatei. Der lief seit dem ersten Tag nicht.
Wer also workspace.db kopiert haette -- der naheliegendste Sicherungsweg
ueberhaupt -- haette eine LEERE Datenbank gesichert und es nicht gemerkt.
Der Test stellt genau das nach: aus der reinen Dateikopie waren 0 von 200
Aufgaben lesbar.
Deshalb:
- Nie eine Dateikopie. Immer VACUUM INTO -- SQLite schreibt selbst, liest
das WAL mit, Ergebnis ist in sich stimmig auch waehrend Schreibzugriffen.
- Vor jeder Sicherung ein erzwungener Checkpoint (TRUNCATE). Haelt die
Hauptdatei aktuell und das WAL klein.
- Jede frische Sicherung wird SOFORT wieder geoeffnet und geprueft: auf
Unversehrtheit UND darauf, dass Personen darin stehen -- gegen genau den
Fall einer technisch einwandfreien, aber leeren Datei. Faellt sie durch,
wird sie geloescht statt behalten.
- Erst unter Zwischennamen schreiben, dann pruefen, dann umbenennen. Sonst
stuende nach einem Abbruch eine halbe Datei unter dem richtigen Namen.
- Aufbewahrung 7 taeglich / 4 woechentlich / 6 monatlich, je Art getrennt
aufgeraeumt, damit taegliche nie die monatlichen verdraengen.
Laeuft IM Prozess, nicht als Systemdienst: keine Aenderung an Systemdateien
noetig, die Datenbank ist ohnehin offen, und es kommt mit jedem git pull
mit. Statt setTimeout auf 24 h wird viertelstuendlich geprueft, ob fuer
HEUTE schon eine liegt -- das ueberlebt Neustarts und Ausfaelle.
Dazu eine Zustandsansicht auf der Automationen-Seite: wann zuletzt, wie
gross, wie viele Personen darin, wie viel wartet noch im WAL, wie viel
Platz ist frei. Plus "Jetzt sichern" von Hand. Nur Leitung, 404 statt 403
fuer alle anderen.
Geprueft (server/pruef-sicherung.mjs, 27 Pruefungen) einschliesslich des
Ernstfalls: Sicherung schreiben, Datenbank zerstoeren, zurueckspielen,
Vollstaendigkeit und Schreibbarkeit nachweisen. Eine ungetestete Sicherung
ist eine Vermutung.
Co-Authored-By: Claude Opus 5 <[email protected]>
326 lines
13 KiB
JavaScript
326 lines
13 KiB
JavaScript
/* ===================================================================
|
|
Sicherung und Zustand.
|
|
|
|
Bis heute wurde nur EINMAL gesichert: direkt vor einer Umstellung der
|
|
Datenbank. Faellt die Platte aus, ist alles weg -- die komplette
|
|
Betreuung, jede Aufgabe, jedes Protokoll. Das war das einzige Risiko
|
|
im ganzen Aufbau, das auf einen Schlag ALLES kostet.
|
|
|
|
Zwei Dinge, die beim Nachsehen auf dem Server auffielen und die den
|
|
Bauplan hier bestimmen:
|
|
|
|
1) Die Datei `workspace.db` war 4 KB gross, die Datei `workspace.db-wal`
|
|
dagegen 2,1 MB. Im WAL-Betrieb landen Schreibvorgaenge zuerst im
|
|
-wal und wandern erst beim "Checkpoint" in die Hauptdatei. Der lief
|
|
seit dem ersten Tag nicht. Wer also die `.db` kopiert haette --
|
|
der naheliegendste Sicherungsweg ueberhaupt -- haette eine LEERE
|
|
Datenbank gesichert und es nicht gemerkt.
|
|
|
|
Deshalb: NIE die Datei kopieren. Immer `VACUUM INTO`. Das laesst
|
|
SQLite selbst schreiben, liest das WAL mit und ergibt eine in sich
|
|
stimmige, aufgeraeumte Datei -- auch waehrend geschrieben wird.
|
|
|
|
2) Vor jeder Sicherung wird zusaetzlich ein Checkpoint erzwungen. Das
|
|
haelt die Hauptdatei aktuell und das WAL klein. Ohne das waechst
|
|
es unbegrenzt weiter.
|
|
|
|
Die Sicherung laeuft IM Programm, nicht als eigener Systemdienst.
|
|
Gruende: keine Aenderung an Systemdateien noetig (die duerfte ich
|
|
nicht ohne Filipe), der Prozess hat die Datenbank ohnehin offen, und
|
|
sie kommt mit jedem `git pull` automatisch mit.
|
|
|
|
Grundsatz wie ueberall hier: Eine gescheiterte Sicherung darf die
|
|
Website nie mitreissen. Alles ist eingepackt, jeder Fehler wird
|
|
vermerkt und ist auf der Automationen-Seite zu sehen.
|
|
=================================================================== */
|
|
|
|
import express from "express";
|
|
import {
|
|
readdirSync, statSync, unlinkSync, mkdirSync, existsSync, statfsSync, renameSync,
|
|
} from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import {
|
|
db, DB_PFAD, DATEN_ORDNER, einstellung, einstellungSetzen, istLeitung, protokolliere,
|
|
} from "./workspace.js";
|
|
|
|
/* node:sqlite wird wie in workspace.js NACHTRAEGLICH geholt, nicht oben
|
|
importiert. Ein `import` waere fest verdrahtet und wuerde beim
|
|
Hochfahren den GESAMTEN Website-Prozess abbrechen, wenn das Modul
|
|
fehlt -- also auch die oeffentliche Seite. So bleibt der Schaden auf
|
|
die Sicherung beschraenkt. */
|
|
const require = createRequire(pathToFileURL(dirname(fileURLToPath(import.meta.url)) + "/"));
|
|
|
|
const ORDNER = join(DATEN_ORDNER, "sicherungen");
|
|
|
|
/* Wie viele wovon aufgehoben werden. Grossvater-Vater-Sohn: viele frische
|
|
fuer "ups, eben geloescht", wenige alte fuer "das war schon letzten
|
|
Monat falsch". Bei 2 MB je Datei kostet das nichts. */
|
|
const BEHALTEN = { taeglich: 7, woechentlich: 4, monatlich: 6 };
|
|
|
|
const zahl2 = (n) => String(n).padStart(2, "0");
|
|
|
|
/* Ortszeit, nicht UTC. Eine Sicherung, die im Namen den 30. traegt, aber
|
|
am 31. lief, verwirrt genau dann, wenn man sie braucht. */
|
|
function heuteOrt(d = new Date()) {
|
|
return `${d.getFullYear()}-${zahl2(d.getMonth() + 1)}-${zahl2(d.getDate())}`;
|
|
}
|
|
|
|
/* Welche Art ist heute faellig? Monatlich schlaegt woechentlich schlaegt
|
|
taeglich -- am 1., der auf einen Sonntag faellt, entsteht sonst
|
|
dieselbe Datei dreimal. */
|
|
function artFuer(d = new Date()) {
|
|
if (d.getDate() === 1) return "monatlich";
|
|
if (d.getDay() === 0) return "woechentlich"; // Sonntag
|
|
return "taeglich";
|
|
}
|
|
|
|
function ordnerSicherstellen() {
|
|
if (!existsSync(ORDNER)) mkdirSync(ORDNER, { recursive: true });
|
|
}
|
|
|
|
export function sicherungenListe() {
|
|
try {
|
|
ordnerSicherstellen();
|
|
return readdirSync(ORDNER)
|
|
.filter((n) => n.endsWith(".db"))
|
|
.map((name) => {
|
|
const s = statSync(join(ORDNER, name));
|
|
return { name, groesse: s.size, zeit: s.mtime.toISOString() };
|
|
})
|
|
.sort((a, b) => b.name.localeCompare(a.name));
|
|
} catch { return []; }
|
|
}
|
|
|
|
/* Alte wegraeumen -- je Art getrennt, damit eine Woche voller taeglicher
|
|
Sicherungen nie die monatlichen verdraengt. */
|
|
function aufraeumen() {
|
|
const alle = sicherungenListe();
|
|
let weg = 0;
|
|
for (const [art, wieviele] of Object.entries(BEHALTEN)) {
|
|
const dieser = alle.filter((s) => s.name.startsWith(art + "-"));
|
|
for (const alt of dieser.slice(wieviele)) {
|
|
try { unlinkSync(join(ORDNER, alt.name)); weg++; } catch { /* egal */ }
|
|
}
|
|
}
|
|
return weg;
|
|
}
|
|
|
|
/* Eine frisch geschriebene Sicherung wird SOFORT wieder geoeffnet und
|
|
geprueft. Eine Sicherung, die man nie aufgemacht hat, ist eine
|
|
Vermutung -- und man merkt es erst an dem Tag, an dem man sie braucht.
|
|
Geprueft wird beides: die Unversehrtheit der Datei UND ob die Personen
|
|
ueberhaupt drinstehen (gegen genau den Fall, der hier drohte: eine
|
|
technisch einwandfreie, aber leere Datei). */
|
|
function pruefen(pfad) {
|
|
const { DatabaseSync } = require("node:sqlite");
|
|
const p = new DatabaseSync(pfad, { readOnly: true });
|
|
try {
|
|
const heil = p.prepare("PRAGMA integrity_check").get();
|
|
const wert = heil ? Object.values(heil)[0] : "";
|
|
if (wert !== "ok") return { gut: false, grund: "integrity_check: " + wert };
|
|
const n = p.prepare("SELECT COUNT(*) AS n FROM personen").get()?.n ?? 0;
|
|
if (n === 0) return { gut: false, grund: "keine Personen in der Sicherung" };
|
|
return { gut: true, personen: n };
|
|
} finally { try { p.close(); } catch { /* egal */ } }
|
|
}
|
|
|
|
export function sicherungJetzt(grund = "planmaessig") {
|
|
const begonnen = Date.now();
|
|
const d = db();
|
|
ordnerSicherstellen();
|
|
|
|
/* Checkpoint zuerst: bringt das WAL in die Hauptdatei und macht es
|
|
wieder klein. TRUNCATE statt PASSIVE, weil PASSIVE aufgibt, sobald
|
|
jemand liest -- und dann waere das WAL nach Wochen immer noch da. */
|
|
let checkpoint = null;
|
|
try {
|
|
checkpoint = d.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
} catch (f) {
|
|
checkpoint = { fehler: f?.message };
|
|
}
|
|
|
|
const art = artFuer();
|
|
const ziel = join(ORDNER, `${art}-${heuteOrt()}.db`);
|
|
|
|
/* Erst neben das Ziel schreiben, dann pruefen, dann umbenennen. Sonst
|
|
stuende nach einem Abbruch eine halbe Datei unter dem richtigen
|
|
Namen -- und die wuerde man fuer gut halten. */
|
|
const vorlaeufig = ziel + ".teil";
|
|
try { if (existsSync(vorlaeufig)) unlinkSync(vorlaeufig); } catch { /* egal */ }
|
|
/* VACUUM INTO weigert sich, wenn die Zieldatei schon existiert. Am
|
|
selben Tag zweimal zu sichern ist erlaubt (etwa von Hand vor einer
|
|
groesseren Aenderung) -- die neue ersetzt dann die alte. */
|
|
try { if (existsSync(ziel)) unlinkSync(ziel); } catch { /* egal */ }
|
|
|
|
d.exec(`VACUUM INTO '${vorlaeufig.replace(/'/g, "''")}'`);
|
|
|
|
const geprueft = pruefen(vorlaeufig);
|
|
if (!geprueft.gut) {
|
|
try { unlinkSync(vorlaeufig); } catch { /* egal */ }
|
|
throw new Error("Sicherung war unbrauchbar: " + geprueft.grund);
|
|
}
|
|
|
|
renameSync(vorlaeufig, ziel);
|
|
const groesse = statSync(ziel).size;
|
|
const weg = aufraeumen();
|
|
|
|
const ergebnis = {
|
|
zeit: new Date().toISOString(),
|
|
datei: `${art}-${heuteOrt()}.db`,
|
|
art,
|
|
groesse,
|
|
personen: geprueft.personen,
|
|
dauer_ms: Date.now() - begonnen,
|
|
geloescht: weg,
|
|
grund,
|
|
checkpoint: checkpoint && !checkpoint.fehler
|
|
? `${checkpoint.busy ?? "?"}/${checkpoint.log ?? "?"}/${checkpoint.checkpointed ?? "?"}`
|
|
: (checkpoint?.fehler || null),
|
|
};
|
|
einstellungSetzen("sicherung_letzte", JSON.stringify(ergebnis));
|
|
einstellungSetzen("sicherung_fehler", "");
|
|
return ergebnis;
|
|
}
|
|
|
|
/* ---------- Zeitsteuerung ---------------------------------------------
|
|
|
|
Kein setTimeout auf 24 Stunden: Ein Neustart -- und sei es nur ein
|
|
Deploy -- setzt ihn zurueck, und dann faellt die Sicherung eines Tages
|
|
still aus. Stattdessen wird viertelstuendlich geschaut, ob fuer HEUTE
|
|
schon eine liegt. Das ueberlebt Neustarts, Zeitumstellung und
|
|
Ausfaelle: Wird der Rechner drei Tage spaeter wieder angeschaltet,
|
|
sichert er sofort statt bis Mitternacht zu warten.
|
|
--------------------------------------------------------------------- */
|
|
|
|
const PRUEFTAKT_MS = 15 * 60 * 1000;
|
|
const STUNDE_AB = 3; // nachts, wenn nichts los ist
|
|
|
|
function faellig() {
|
|
const jetzt = new Date();
|
|
const letzteRoh = einstellung("sicherung_letzte");
|
|
let letzte = null;
|
|
try { letzte = letzteRoh ? JSON.parse(letzteRoh) : null; } catch { /* kaputt = keine */ }
|
|
|
|
/* Liegt fuer heute schon eine? Geprueft wird die DATEI, nicht nur der
|
|
Vermerk -- sonst haelt ein Eintrag ohne Datei die Sicherung auf. */
|
|
const heute = heuteOrt(jetzt);
|
|
const daFuerHeute = sicherungenListe().some((s) => s.name.includes(heute));
|
|
if (daFuerHeute) return false;
|
|
|
|
/* Vor drei Uhr nur dann, wenn seit ueber einem Tag gar nichts kam --
|
|
dann war die Maschine aus und es soll nicht bis zur naechsten Nacht
|
|
dauern. */
|
|
if (jetzt.getHours() >= STUNDE_AB) return true;
|
|
if (!letzte?.zeit) return true;
|
|
return Date.now() - Date.parse(letzte.zeit) > 30 * 3600_000;
|
|
}
|
|
|
|
let laeuft = false;
|
|
|
|
function versuchen() {
|
|
if (laeuft) return;
|
|
laeuft = true;
|
|
try {
|
|
if (!faellig()) return;
|
|
const e = sicherungJetzt("planmaessig");
|
|
console.log(`[sicherung] ${e.datei} · ${Math.round(e.groesse / 1024)} KB · `
|
|
+ `${e.personen} Personen · ${e.dauer_ms} ms`
|
|
+ (e.geloescht ? ` · ${e.geloescht} alte entfernt` : ""));
|
|
} catch (fehler) {
|
|
/* Laut, aber nicht toedlich. Der Fehler landet zusaetzlich in den
|
|
Einstellungen, damit er auf der Automationen-Seite steht -- eine
|
|
Fehlermeldung, die nur im Systemprotokoll steht, liest niemand. */
|
|
console.error("[sicherung] fehlgeschlagen:", fehler?.message);
|
|
try {
|
|
einstellungSetzen("sicherung_fehler",
|
|
JSON.stringify({ zeit: new Date().toISOString(), text: String(fehler?.message || fehler) }));
|
|
} catch { /* egal */ }
|
|
} finally { laeuft = false; }
|
|
}
|
|
|
|
export function sicherungStarten() {
|
|
/* Nicht sofort beim Hochfahren: Erst soll die Website antworten. Und
|
|
die Datenbank oeffnet ohnehin erst beim ersten echten Zugriff -- ein
|
|
Sicherungslauf im Startmoment wuerde sie nur unnoetig frueh oeffnen
|
|
und einen Fehler dort zum Startfehler machen. */
|
|
const ersterLauf = setTimeout(versuchen, 60_000);
|
|
const takt = setInterval(versuchen, PRUEFTAKT_MS);
|
|
ersterLauf.unref?.();
|
|
takt.unref?.();
|
|
}
|
|
|
|
/* ---------- Zustandsseite --------------------------------------------- */
|
|
|
|
export const sicherungRouter = express.Router();
|
|
|
|
function nurLeitung(req, res, next) {
|
|
if (!istLeitung(req.person)) return res.status(404).json({ fehler: "nicht gefunden" });
|
|
next();
|
|
}
|
|
|
|
sicherungRouter.get("/workspace/api/zustand", nurLeitung, (req, res) => {
|
|
try {
|
|
const d = db();
|
|
const lies = (s) => { try { return JSON.parse(einstellung(s) || "null"); } catch { return null; } };
|
|
|
|
let platte = null;
|
|
try {
|
|
const s = statfsSync(DATEN_ORDNER);
|
|
platte = { frei: s.bavail * s.bsize, gesamt: s.blocks * s.bsize };
|
|
} catch { /* nicht ueberall vorhanden */ }
|
|
|
|
/* Die Groesse des WAL ist die Zahl, an der genau der Fehler
|
|
aufgefallen waere, den es hier gab. Sie gehoert deshalb sichtbar
|
|
auf die Seite und nicht nur in ein Protokoll. */
|
|
const dateien = {};
|
|
for (const [schluessel, pfad] of [["db", DB_PFAD], ["wal", DB_PFAD + "-wal"]]) {
|
|
try { dateien[schluessel] = statSync(pfad).size; } catch { dateien[schluessel] = null; }
|
|
}
|
|
|
|
const heil = (() => {
|
|
try {
|
|
const r = d.prepare("PRAGMA quick_check").get();
|
|
return r ? Object.values(r)[0] : null;
|
|
} catch { return null; }
|
|
})();
|
|
|
|
const liste = sicherungenListe();
|
|
res.json({
|
|
letzte: lies("sicherung_letzte"),
|
|
fehler: lies("sicherung_fehler"),
|
|
sicherungen: liste.slice(0, 20),
|
|
anzahl: liste.length,
|
|
belegt: liste.reduce((n, s) => n + s.groesse, 0),
|
|
behalten: BEHALTEN,
|
|
dateien,
|
|
platte,
|
|
heil,
|
|
journal: (() => {
|
|
try { return Object.values(d.prepare("PRAGMA journal_mode").get() || {})[0]; }
|
|
catch { return null; }
|
|
})(),
|
|
seit: Math.round(process.uptime()),
|
|
stand: new Date().toISOString(),
|
|
});
|
|
} catch (fehler) {
|
|
console.error("[zustand]", fehler?.message);
|
|
res.status(500).json({ fehler: "Zustand konnte nicht gelesen werden." });
|
|
}
|
|
});
|
|
|
|
/* Von Hand ausloesen -- vor einer groesseren Aenderung will man nicht bis
|
|
drei Uhr nachts warten. Nur die Leitung, und es wird protokolliert. */
|
|
sicherungRouter.post("/workspace/api/zustand/sichern", nurLeitung, (req, res) => {
|
|
try {
|
|
const e = sicherungJetzt("von Hand · " + (req.person?.name || "?"));
|
|
protokolliere("sicherung_erstellt", {
|
|
personId: req.person?.id, rolle: req.person?.rolle, detail: e.datei,
|
|
});
|
|
res.json(e);
|
|
} catch (fehler) {
|
|
res.status(500).json({ fehler: String(fehler?.message || fehler) });
|
|
}
|
|
});
|