backup: gegenpruefen, nachholen, und endlich Bescheid sagen
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

Befund 2, 3 und 4 aus docs/befunde-2026-08-12.md. Befund 1 bleibt offen, Befund
5 war falsch und ist zurueckgezogen.

DER BOT SAGT JETZT BESCHEID. src/melden.js ist neu und enthaelt, was vorher in
watchdog.js eingeschlossen war: dmAdmin. Genau deshalb hat alles andere im Bot
geschwiegen oder in die Konsole geschrieben -- und eine Konsolenzeile in einem
Container liest niemand.

Dazu dmAdminEinmalig: meldet nur bei ZUSTANDSWECHSEL. Sonst wuerde eine
Pruefung, die alle zehn Minuten laeuft, denselben Ausfall alle zehn Minuten
melden, und 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, haette sonst nach jedem Update wieder eine frische Meinung.
Gemessen gegen eine KOPIE der Datenbank, sechs Schritte, alle wie beabsichtigt.

DER WACHHUND AUF last_backup. pruefeSicherung() meldet, wenn die letzte
Sicherung aelter als 26 Stunden ist, wenn sie fehlgeschlagen ist, oder wenn es
nie eine gab. Im Fehlerfall steht NICHT last_backup als Zeitpunkt im Embed: der
wird nur bei Erfolg gesetzt, dort staende also der letzte GUTE Lauf, und das
liesse die Sicherung frischer aussehen als sie ist.

DER TAG FAELLT NICHT MEHR AUS. Vorher "if (now.getHours() !== 3) return" -- wer
waehrend dieser einen Stunde unten war, hatte den Tag verloren, und ein Bot,
der nach jedem Update neu startet, ist genau dieser Fall. Jetzt zaehlt nur: es
ist nach 03:00 und heute war noch keine.

DAS ARCHIV WIRD AUSGEPACKT UND GEZAEHLT. integrity_check plus Tabellenzahl
gegen die laufende Datenbank. Faellt das durch, wird das Archiv GELOESCHT (im
Sicherungsordner saehe es sonst aus wie eine Sicherung), die Rotation laeuft
nicht, und last_backup bleibt stehen.

An echten Archiven gemessen, mit zwei Gegenproben, damit die Pruefung nicht nur
"ja" sagen kann:

    2026-08-10  817 KB  54 Tabellen  86 ms
    2026-08-11  886 KB  54 Tabellen  64 ms
    2026-08-12  970 KB  55 Tabellen  78 ms   (laufend: 55)
    halbes gzip -> "unexpected end of file"
    Muell       -> "incorrect header check"

Die 54 gegen 55 sind kein Fehler, sondern eine gewachsene Tabelle zwischen dem
11. und dem 12. Verglichen wird zeitgleich, also stoert das nicht -- wissen
sollte man es, bevor jemand alte Archive gegen die heutige Zahl haelt.

BEFUND 5 WAR FALSCH. pruneMessageCache wird sehr wohl aufgerufen, taeglich, in
mod-tools.js:239 -- und stand schon zum Zeitpunkt der Durchsicht dort. Gezaehlt
wurden "Aufrufe ausserhalb von db.js", und die Sammel-Importzeile ist als
Import durchgegangen, waehrend der Aufruf zwanzig Zeilen tiefer nicht mitkam.
Das ist genau der Fehler, den zu vermeiden dieser Bericht dasteht; die anderen
vier sind deshalb einzeln nachgemessen worden und stimmen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 14:47:09 +02:00
co-authored by Claude Opus 5
parent 1808b7c40b
commit 93156e5205
4 changed files with 372 additions and 32 deletions
+198 -14
View File
@@ -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
? `<t:${Math.floor(new Date(fehlerAt).getTime() / 1000)}:R>`
: 'unbekannt',
inline: true,
},
{
name: 'Letzte GUTE Sicherung',
value: `<t:${Math.floor(new Date(roh).getTime() / 1000)}:R>`,
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: `<t:${Math.floor(new Date(roh).getTime() / 1000)}:R>`,
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();
+1 -6
View File
@@ -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,
+57
View File
@@ -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());
}