diff --git a/docs/befunde-2026-08-12.md b/docs/befunde-2026-08-12.md index 5f63a44..7c888fd 100644 --- a/docs/befunde-2026-08-12.md +++ b/docs/befunde-2026-08-12.md @@ -128,22 +128,39 @@ Ein Backup, das man nie zurückgespielt hat, ist keins. --- -## 5 · `pruneMessageCache` wird nie aufgerufen +## ~~5 · `pruneMessageCache` wird nie aufgerufen~~ — FALSCH, zurückgezogen am 12.08.2026 -``` -prunePlayerHistory 1 Aufruf ausserhalb db.js -pruneWatchdogHistory 1 -pruneHeartbeat 1 -pruneIncidents 1 -pruneMessageCache 0 <- +**Dieser Befund war falsch.** Der Aufruf steht in `src/bot/mod-tools.js:239`, in +`registerModTools`, mit eigenem Kommentar — und er stand schon zum Zeitpunkt der +Durchsicht dort (nachgesehen in `git show 1808b7c:src/bot/mod-tools.js`): + +```js +const aufraeumen = () => { + const weg = pruneMessageCache(Math.max(0, tuning('modlog_cache_days'))); + if (weg > 0) console.log(`[modlog] ${weg} alte Nachrichten vergessen`); +}; +aufraeumen(); +setInterval(aufraeumen, 86_400_000); ``` -Exakt dasselbe Muster, das in `d4rk_media` bei `pruneEvents` gefunden wurde: die -Funktion ist da, die Aufbewahrungsfrist ist dokumentiert, der Aufruf fehlt. +Täglich, mit Frist aus der Feineinstellung. **Es ist nichts zu tun.** -**Heute harmlos** — die Tabelle hat 18 Zeilen. Aber die Frist greift nicht, und -wenn der Nachrichten-Zwischenspeicher je stärker benutzt wird, wächst er ohne -Ende. +Wie es passieren konnte: gezählt wurden „Aufrufe außerhalb von `db.js`" — und +`mod-tools.js` importiert die Funktion in einer Sammelzeile mit fünf anderen +Namen. Beim Zählen ist die Zeile als Import durchgegangen und der Aufruf +zwanzig Zeilen weiter unten nicht mitgezählt worden. + +**Das ist genau der Fehler, den zu vermeiden dieser Bericht dasteht:** eine +plausible Zahl, die niemand am Gegenstand nachgeprüft hat. Die anderen vier +Befunde sind deshalb am 12.08.2026 alle noch einmal einzeln nachgemessen worden +— sie stimmen: + +| Befund | Nachgemessen | +|---|---| +| 1 · Sicherung ohne sicheren Ort | `backup_channel_id` ist **leer** — der Offsite-Zweig lief nie | +| 2 · Ausbleiben fällt niemandem auf | `last_backup` kommt an drei Stellen vor: schreiben, Tagesbremse, anzeigen. **Nirgends ein Vergleich mit heute.** | +| 3 · Zu groß endet in der Konsole | `console.warn` in `backup.js`, unverändert | +| 4 · Archiv nie gegengeprüft | kein Auspacken, kein Öffnen, kein Zählen im ganzen Modul | --- @@ -189,3 +206,90 @@ eine Kopie neben dem Original, deren Ausfall unbemerkt bliebe. Der billigste Schritt ist `backup_channel_id` zu setzen — ein Klick, und der vorgesehene Weg funktioniert. Der solide Schritt ist derselbe wie bei `d4rk_media`: abholen, prüfen, in die Nextcloud, und ein Wachhund darauf. + +--- + +## Was am 12.08.2026 daraus gebaut wurde + +**Befund 2, 3 und 4 sind erledigt** — Befund 1 zur Hälfte (siehe unten). + +### Der Bot sagt jetzt Bescheid + +`src/melden.js` ist neu und enthält das, was vorher in `watchdog.js` +eingeschlossen war: `dmAdmin`. Deshalb hat alles andere im Bot geschwiegen oder +in die Konsole geschrieben — **eine Konsolenzeile in einem Container liest +niemand.** + +Dazu `dmAdminEinmalig(client, schluessel, zustand, …)`: meldet nur, wenn sich +der Zustand *geändert* hat. Ohne das würde eine Prüfung, die alle zehn Minuten +läuft, denselben Ausfall alle zehn Minuten melden — nach der dritten DM liest +man sie nicht mehr. Der Merker steht in den Einstellungen und nicht im +Arbeitsspeicher: ein Bot, der nach jedem Update neu startet, hätte sonst nach +jedem Update wieder eine frische Meinung. + +Nachgemessen gegen eine **Kopie** der Datenbank, sechs Schritte: + +``` +1. Ausfall zum ersten Mal gebaut: 1 Merker: "alt:…T03" +2. derselbe Ausfall noch zweimal gebaut: 0 Merker: "alt:…T03" +3. ANDERER Ausfall gebaut: 1 Merker: "fehler:…T09" +4. wieder in Ordnung (null) gebaut: 0 Merker: "" +5. derselbe Ausfall danach erneut gebaut: 1 Merker: "alt:…T03" +``` + +### Der Wachhund auf `last_backup` + +`pruefeSicherung(client)` läuft im selben Zehn-Minuten-Takt wie der Planer und +meldet, wenn die letzte Sicherung älter als **26 Stunden** ist, wenn die letzte +fehlgeschlagen ist, oder wenn es noch nie eine gab. + +Beim Fehlerfall steht **nicht** `last_backup` als Zeitpunkt im Embed: der wird +nur bei Erfolg gesetzt, dort stünde also der letzte *gute* Lauf, und das ließe +die Sicherung frischer aussehen, als sie ist. Der Zeitpunkt des Fehlschlags +steht getrennt daneben (`last_backup_fehler_at`). + +### Der Tag fällt nicht mehr aus + +Vorher: + +```js +if (now.getHours() !== 3) return; +``` + +Wer während dieser **einen Stunde** unten war, hatte den Tag verloren — und ein +Bot, der nach jedem Update neu startet, ist genau dieser Fall. Jetzt zählt nur +noch: es ist nach 03:00 und heute war noch keine. Der Lauf wird also nachgeholt. + +### Das Archiv wird ausgepackt und gezählt + +Nach dem Zippen wird das Archiv wieder ausgepackt, die Datenbank darin geöffnet, +`PRAGMA integrity_check` gefahren und die Tabellen gezählt — gegen die +**laufende** Datenbank. Fällt das durch, wird das Archiv **gelöscht** (im +Sicherungsordner sähe es sonst aus wie eine Sicherung), die Rotation läuft +nicht, und `last_backup` bleibt stehen. + +An den echten Archiven gemessen, und mit zwei Gegenproben, damit die Prüfung +nicht nur „ja" sagen kann: + +``` +laufende Datenbank: 55 Tabellen + +2026-08-10 817 KB 54 Tabellen, 86 Einstellungen 86 ms +2026-08-11 886 KB 54 Tabellen, 86 Einstellungen 64 ms +2026-08-12 970 KB 55 Tabellen, 86 Einstellungen 78 ms + +halbes gzip -> abgelehnt: "unexpected end of file" +Müll -> abgelehnt: "incorrect header check" +``` + +Die 54 gegen 55 sind übrigens kein Fehler, sondern eine **gewachsene** Tabelle +zwischen dem 11. und dem 12. Verglichen wird zeitgleich — Archiv gegen die +Datenbank, aus der es gerade entstanden ist —, also stört das nicht. Wissen +sollte man es trotzdem, bevor jemand alte Archive gegen die heutige Zahl hält. + +### Was offen bleibt: Befund 1 + +Die Sicherung liegt **weiterhin im selben Volume** wie die Datenbank. Der +eingebaute Ausweg (`backup_channel_id`) braucht eine Kanal-ID, die nur du geben +kannst — ein privater Kanal, in dem der Bot Dateien anhängen darf. Ein Klick in +Setup → Einstellungen, und der Zweig läuft. diff --git a/src/backup.js b/src/backup.js index 2518784..6f71a53 100644 --- a/src/backup.js +++ b/src/backup.js @@ -3,20 +3,70 @@ import { createReadStream, createWriteStream, mkdirSync, readdirSync, statSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { pipeline } from 'node:stream/promises'; -import { createGzip } from 'node:zlib'; +import { createGzip, createGunzip } from 'node:zlib'; import { join, dirname, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import Database from 'better-sqlite3'; import { AttachmentBuilder } from 'discord.js'; import { db, getSetting, setSetting } from './db.js'; import { config } from './config.js'; +import { brandEmbed } from './embeds.js'; +import { dmAdmin, dmAdminEinmalig } from './melden.js'; const CHECK_INTERVAL_MS = 10 * 60 * 1000; const KEEP_DAYS = 14; const MAX_UPLOAD_BYTES = 9 * 1024 * 1024; // Discord-Limit für Bot-Uploads (10 MB, Puffer) +/** Ab wann eine ausgebliebene Sicherung gemeldet wird. + * + * 26 Stunden: die Sicherung läuft täglich um 03:00, also sind 24 zu knapp + * (ein Lauf um 03:05 und der nächste um 02:55 lägen 23:50 auseinander) und 48 + * zu spät — dann fehlten schon zwei. */ +const UEBERFAELLIG_STUNDEN = 26; + export const backupDir = join(dirname(resolve(config.dbPath)), 'backups'); mkdirSync(backupDir, { recursive: true }); -/** Backup erstellen; gibt { file, sizeBytes } zurück */ +// ---------------------------------------------------------------- Nachzählen +// +// EIN ARCHIV, DAS NIEMAND GEGENGEPRÜFT HAT, IST EINE VERMUTUNG. Bisher wurde +// es geschrieben, gezippt, hochgeladen — und nie ausgepackt, nie geöffnet, nie +// gezählt. Ein halbes gzip oder eine Datei, die beim Schreiben auf eine volle +// Platte abgebrochen ist, fiele erst an dem Tag auf, an dem man sie braucht. +// +// Es kostet fast nichts: das Archiv ist unter einem Megabyte, das Auspacken +// dauert Millisekunden. Verglichen wird mit der LAUFENDEN Datenbank — eine +// Sicherung mit weniger Tabellen als das Original ist keine. + +/** Packt das Archiv aus, öffnet die Datenbank darin und zählt. + * @returns {{ tabellen: number, settings: number }} + * @throws wenn es sich nicht auspacken, nicht öffnen oder nicht lesen lässt */ +async function pruefeArchiv(gzFile) { + const probe = join(tmpdir(), `d4rkbot-probe-${process.pid}.db`); + try { + await pipeline(createReadStream(gzFile), createGunzip(), createWriteStream(probe)); + + const kopie = new Database(probe, { readonly: true }); + try { + // integrity_check zuerst: eine Datei kann sich öffnen lassen und + // trotzdem seitenweise Müll enthalten. + const urteil = kopie.pragma('integrity_check', { simple: true }); + if (urteil !== 'ok') throw new Error(`integrity_check sagt "${urteil}"`); + + const tabellen = kopie + .prepare(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'`) + .get().n; + const settings = kopie.prepare('SELECT COUNT(*) AS n FROM settings').get().n; + return { tabellen, settings }; + } finally { + kopie.close(); + } + } finally { + await rm(probe, { force: true }); + } +} + +/** Backup erstellen; gibt { file, sizeBytes, geprueft } zurück */ export async function runBackup(client) { const stamp = new Date().toISOString().slice(0, 10); const rawFile = join(backupDir, `d4rkbot-${stamp}.db`); @@ -27,6 +77,27 @@ export async function runBackup(client) { await pipeline(createReadStream(rawFile), createGzip({ level: 9 }), createWriteStream(gzFile)); await rm(rawFile, { force: true }); + const sizeBytes = statSync(gzFile).size; + + // AUSPACKEN UND NACHZÄHLEN, bevor irgendetwas anderes passiert. Ein Archiv, + // das die Prüfung nicht besteht, darf weder die Rotation auslösen (sonst + // fliegt ein gutes altes für ein kaputtes neues) noch als "letzte + // Sicherung" gelten. + const erwartet = db.prepare(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'`).get().n; + let drin; + try { + drin = await pruefeArchiv(gzFile); + if (drin.tabellen < erwartet) { + throw new Error(`Archiv unvollständig: ${drin.tabellen} Tabellen statt ${erwartet}`); + } + } catch (fehler) { + // WEG DAMIT. Ein Archiv, das die Prüfung nicht besteht, darf nicht im + // Sicherungsordner liegen bleiben: dort sieht es aus wie eine + // Sicherung, und die Liste im Panel zählt es mit. + await rm(gzFile, { force: true }); + throw fehler; + } + // Rotation: alles älter als KEEP_DAYS löschen const cutoff = Date.now() - KEEP_DAYS * 86400000; for (const f of readdirSync(backupDir)) { @@ -36,10 +107,9 @@ export async function runBackup(client) { } } - const sizeBytes = statSync(gzFile).size; - // Optionaler Upload in einen (privaten!) Discord-Kanal — Offsite-Kopie const channelId = getSetting('backup_channel_id'); + let hoch = false; if (channelId && client) { if (sizeBytes <= MAX_UPLOAD_BYTES) { const channel = await client.channels.fetch(channelId).catch(() => null); @@ -48,32 +118,146 @@ export async function runBackup(client) { content: `💾 Backup ${stamp} (${(sizeBytes / 1024).toFixed(0)} KB)`, files: [new AttachmentBuilder(gzFile)], }); + hoch = true; } } else { + // NICHT NUR IN DIE KONSOLE. Das Archiv wächst um rund 70 KB am Tag; + // wenn die Grenze reißt, hört die Kopie außerhalb des Volumes auf — + // still. Eine Konsolenzeile in einem Container liest niemand. console.warn(`[backup] ${gzFile} zu groß für Discord-Upload (${sizeBytes} B)`); + await dmAdminEinmalig(client, 'backup_zu_gross', stamp, () => + brandEmbed(client, 'BACKUP', { secondary: true }) + .setTitle('⚠️ Sicherung passt nicht mehr nach Discord') + .setDescription( + 'Die Kopie **außerhalb** des Volumes ist damit ausgefallen. ' + + 'Die Sicherung selbst liegt noch neben der Datenbank — also am ' + + 'selben Ort wie das Original.', + ) + .addFields( + { name: 'Archiv', value: `${(sizeBytes / 1048576).toFixed(1)} MB`, inline: true }, + { name: 'Grenze', value: `${(MAX_UPLOAD_BYTES / 1048576).toFixed(0)} MB`, inline: true }, + )); } } setSetting('last_backup', new Date().toISOString()); - console.log(`[backup] ${gzFile} erstellt (${(sizeBytes / 1024).toFixed(0)} KB)`); - return { file: gzFile, sizeBytes }; + // Den Fehlerstand mit aufräumen: bliebe er stehen, meldete der Wachhund + // einen Fehlschlag, der längst behoben ist. + setSetting('last_backup_ok', '1'); + setSetting('last_backup_fehler', ''); + console.log( + `[backup] ${gzFile} erstellt (${(sizeBytes / 1024).toFixed(0)} KB), ` + + `geprueft: ${drin.tabellen} Tabellen, ${drin.settings} Einstellungen`, + ); + return { file: gzFile, sizeBytes, geprueft: drin, hoch }; } -/** Scheduler: täglich zwischen 03:00 und 04:00 (lokale TZ), einmal pro Tag */ +// ------------------------------------------------------------- Der Wachhund +// +// `last_backup` wurde geschrieben, im Panel angezeigt und als Tagesbremse +// gelesen — aber nirgends mit HEUTE verglichen. Niemand beschwerte sich also, +// wenn die Sicherung ausblieb. +// +// Zusammen mit dem alten Zeitfenster (`if (now.getHours() !== 3) return`) war +// das der eigentliche Schaden: war der Bot während der Stunde 03 unten — und +// er startet nach jedem Update neu —, fiel der Tag aus, und zwar lautlos. + +/** Prüft, ob die letzte Sicherung zu lange her ist, und meldet einmal je + * Zustand. Wirft nie. */ +export async function pruefeSicherung(client) { + try { + if (getSetting('backup_enabled') === '0') return; + + const roh = getSetting('last_backup'); + if (!roh) { + await dmAdminEinmalig(client, 'backup_warn', 'nie', () => + brandEmbed(client, 'BACKUP', { secondary: true }) + .setTitle('⚠️ Noch keine Sicherung') + .setDescription('Es wurde noch **nie** eine Sicherung eingetragen.')); + return; + } + + const stunden = (Date.now() - new Date(roh).getTime()) / 3_600_000; + + if (getSetting('last_backup_ok') === '0') { + // ACHTUNG BEIM DATUM: `last_backup` wird nur bei ERFOLG gesetzt. + // Nach einem Fehlschlag steht dort also der letzte GUTE Lauf, und + // ihn als "letzten Versuch" zu zeigen wäre eine Falschaussage -- + // sie ließe die Sicherung frischer aussehen, als sie ist. Der + // Zeitpunkt des Fehlschlags steht deshalb getrennt daneben. + const fehlerAt = getSetting('last_backup_fehler_at'); + await dmAdminEinmalig(client, 'backup_warn', `fehler:${fehlerAt ?? roh}`, () => + brandEmbed(client, 'BACKUP', { secondary: true }) + .setTitle('🚨 Die Sicherung ist fehlgeschlagen') + .setDescription(getSetting('last_backup_fehler') || 'Kein Grund gemeldet.') + .addFields( + { + name: 'Fehlgeschlagen', + value: fehlerAt + ? `` + : 'unbekannt', + inline: true, + }, + { + name: 'Letzte GUTE Sicherung', + value: ``, + inline: true, + }, + )); + } else if (stunden > UEBERFAELLIG_STUNDEN) { + await dmAdminEinmalig(client, 'backup_warn', `alt:${roh.slice(0, 13)}`, () => + brandEmbed(client, 'BACKUP', { secondary: true }) + .setTitle('⚠️ Die Sicherung ist überfällig') + .setDescription('Erwartet wird sie täglich um 03:00. Läuft der Bot durch?') + .addFields( + { + name: 'Letzter Lauf', + value: ``, + inline: true, + }, + { name: 'Das ist', value: `${Math.floor(stunden)} Stunden her`, inline: true }, + )); + } else { + // Wieder in Ordnung: den Merker löschen, damit derselbe Zustand + // später erneut gemeldet werden kann. + await dmAdminEinmalig(client, 'backup_warn', null); + } + } catch (error) { + console.error('[backup] Wachhund:', error.message); + } +} + +/** Scheduler: täglich ab 03:00 (lokale TZ), einmal pro Tag — und er HOLT NACH. + * + * Vorher stand hier `if (now.getHours() !== 3) return`. Wer während dieser + * einen Stunde unten war, hatte den Tag verloren; ein Bot, der nach jedem + * Update neu startet, ist genau dieser Fall. Jetzt zählt nur noch: es ist + * nach 03:00 und heute war noch keine. */ export function scheduleBackups(client) { const check = async () => { if (getSetting('backup_enabled') === '0') return; const now = new Date(); - if (now.getHours() !== 3) return; - const today = now.toISOString().slice(0, 10); - if ((getSetting('last_backup') ?? '').slice(0, 10) === today) return; + const heuteSchon = (getSetting('last_backup') ?? '').slice(0, 10) + === now.toISOString().slice(0, 10); - try { - await runBackup(client); - } catch (error) { - console.error('[backup] Fehlgeschlagen:', error); + if (!heuteSchon && now.getHours() >= 3) { + try { + await runBackup(client); + } catch (error) { + console.error('[backup] Fehlgeschlagen:', error); + // Der Fehler gehört in die Datenbank UND in eine DM. Bisher + // endete er in der Konsole eines Containers. + setSetting('last_backup_ok', '0'); + setSetting('last_backup_fehler', String(error?.message ?? error).slice(0, 500)); + setSetting('last_backup_fehler_at', new Date().toISOString()); + await dmAdmin(client, brandEmbed(client, 'BACKUP', { secondary: true }) + .setTitle('🚨 Die Sicherung ist fehlgeschlagen') + .setDescription(String(error?.message ?? error).slice(0, 500))); + } } + + await pruefeSicherung(client); }; setInterval(check, CHECK_INTERVAL_MS); check(); diff --git a/src/bot/watchdog.js b/src/bot/watchdog.js index 02a2d17..f0029a4 100644 --- a/src/bot/watchdog.js +++ b/src/bot/watchdog.js @@ -8,6 +8,7 @@ import { import { config } from '../config.js'; import { moduleEnabled } from '../modules.js'; import { brandEmbed } from '../embeds.js'; +import { dmAdmin } from '../melden.js'; import { tuning, tuningMs, everyTuned } from '../tuning.js'; // URL → { fails, down, since } @@ -24,12 +25,6 @@ export function watchedServices() { return listMonitored().filter((s) => /^https?:\/\//.test(s.url)); } -async function dmAdmin(client, embed) { - if (!config.adminDiscordId) return; - const user = await client.users.fetch(config.adminDiscordId).catch(() => null); - await user?.send({ embeds: [embed] }).catch(() => {}); -} - // ---------------------------------------------------------------- Der Grund // // Bis hierhin stand im Ausfall-Embed nur "HTTP 503". Das sagt, DASS etwas ist, diff --git a/src/melden.js b/src/melden.js new file mode 100644 index 0000000..a7f52cd --- /dev/null +++ b/src/melden.js @@ -0,0 +1,57 @@ +// Eine DM an den Betreiber. Mehr ist es nicht — aber es war bisher in +// watchdog.js eingeschlossen, und deshalb hat alles andere im Bot geschwiegen +// oder in die Konsole geschrieben. +// +// EINE KONSOLENZEILE IN EINEM CONTAINER LIEST NIEMAND. Das ist derselbe Fehler +// wie ein Knopf ohne Rückmeldung, nur langsamer: es sieht monatelang gut aus, +// und der Tag, an dem man merkt, dass es nicht gut war, ist der Tag, an dem man +// die Sicherung braucht. +import { config } from './config.js'; +import { getSetting, setSetting } from './db.js'; + +/** Ein Embed als DM an den Betreiber. Wirft nie — eine fehlgeschlagene Meldung + * darf den Vorgang nicht mitreißen, über den sie meldet. */ +export async function dmAdmin(client, embed) { + if (!config.adminDiscordId || !client) return false; + try { + const user = await client.users.fetch(config.adminDiscordId).catch(() => null); + if (!user) return false; + await user.send({ embeds: [embed] }); + return true; + } catch { + return false; + } +} + +/** + * Wie `dmAdmin`, aber nur, wenn sich der Zustand geändert hat. + * + * WOZU: eine Prüfung, die alle zehn Minuten läuft, würde einen Ausfall alle + * zehn Minuten melden. Nach der dritten DM liest man sie nicht mehr, und nach + * der dreißigsten ist der Kanal unbrauchbar für die Meldung, auf die es + * ankommt. + * + * Der Merker steht in den Einstellungen und nicht im Arbeitsspeicher: ein Bot, + * der nach jedem Update neu startet, hätte sonst nach jedem Update wieder eine + * frische Meinung — und meldete denselben Ausfall erneut. + * + * @param {string} schluessel Merker-Name, z. B. 'backup_warn' + * @param {string|null} zustand Kennung des JETZIGEN Zustands. `null` heißt + * „alles in Ordnung": der Merker wird gelöscht, damit dieselbe Sache später + * wieder gemeldet werden kann. + */ +export async function dmAdminEinmalig(client, schluessel, zustand, embedBauen = null) { + const merker = `melden.${schluessel}`; + const zuletzt = getSetting(merker) ?? ''; + + if (!zustand) { + if (zuletzt) setSetting(merker, ''); + return false; + } + if (zuletzt === zustand) return false; + + // ERST merken, dann senden. Andersherum gäbe eine Discord-Störung eine + // Meldung je Durchlauf -- also genau das, was hier verhindert werden soll. + setSetting(merker, zustand); + return dmAdmin(client, embedBauen()); +}