backup: gegenpruefen, nachholen, und endlich Bescheid sagen
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:
+116
-12
@@ -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
|
||||||
|
|
||||||
```
|
**Dieser Befund war falsch.** Der Aufruf steht in `src/bot/mod-tools.js:239`, in
|
||||||
prunePlayerHistory 1 Aufruf ausserhalb db.js
|
`registerModTools`, mit eigenem Kommentar — und er stand schon zum Zeitpunkt der
|
||||||
pruneWatchdogHistory 1
|
Durchsicht dort (nachgesehen in `git show 1808b7c:src/bot/mod-tools.js`):
|
||||||
pruneHeartbeat 1
|
|
||||||
pruneIncidents 1
|
```js
|
||||||
pruneMessageCache 0 <-
|
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
|
Täglich, mit Frist aus der Feineinstellung. **Es ist nichts zu tun.**
|
||||||
Funktion ist da, die Aufbewahrungsfrist ist dokumentiert, der Aufruf fehlt.
|
|
||||||
|
|
||||||
**Heute harmlos** — die Tabelle hat 18 Zeilen. Aber die Frist greift nicht, und
|
Wie es passieren konnte: gezählt wurden „Aufrufe außerhalb von `db.js`" — und
|
||||||
wenn der Nachrichten-Zwischenspeicher je stärker benutzt wird, wächst er ohne
|
`mod-tools.js` importiert die Funktion in einer Sammelzeile mit fünf anderen
|
||||||
Ende.
|
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
|
Der billigste Schritt ist `backup_channel_id` zu setzen — ein Klick, und der
|
||||||
vorgesehene Weg funktioniert. Der solide Schritt ist derselbe wie bei
|
vorgesehene Weg funktioniert. Der solide Schritt ist derselbe wie bei
|
||||||
`d4rk_media`: abholen, prüfen, in die Nextcloud, und ein Wachhund darauf.
|
`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.
|
||||||
|
|||||||
+194
-10
@@ -3,20 +3,70 @@
|
|||||||
import { createReadStream, createWriteStream, mkdirSync, readdirSync, statSync } from 'node:fs';
|
import { createReadStream, createWriteStream, mkdirSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { rm } from 'node:fs/promises';
|
import { rm } from 'node:fs/promises';
|
||||||
import { pipeline } from 'node:stream/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 { join, dirname, resolve } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
import { AttachmentBuilder } from 'discord.js';
|
import { AttachmentBuilder } from 'discord.js';
|
||||||
import { db, getSetting, setSetting } from './db.js';
|
import { db, getSetting, setSetting } from './db.js';
|
||||||
import { config } from './config.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 CHECK_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
const KEEP_DAYS = 14;
|
const KEEP_DAYS = 14;
|
||||||
const MAX_UPLOAD_BYTES = 9 * 1024 * 1024; // Discord-Limit für Bot-Uploads (10 MB, Puffer)
|
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');
|
export const backupDir = join(dirname(resolve(config.dbPath)), 'backups');
|
||||||
mkdirSync(backupDir, { recursive: true });
|
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) {
|
export async function runBackup(client) {
|
||||||
const stamp = new Date().toISOString().slice(0, 10);
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
const rawFile = join(backupDir, `d4rkbot-${stamp}.db`);
|
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 pipeline(createReadStream(rawFile), createGzip({ level: 9 }), createWriteStream(gzFile));
|
||||||
await rm(rawFile, { force: true });
|
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
|
// Rotation: alles älter als KEEP_DAYS löschen
|
||||||
const cutoff = Date.now() - KEEP_DAYS * 86400000;
|
const cutoff = Date.now() - KEEP_DAYS * 86400000;
|
||||||
for (const f of readdirSync(backupDir)) {
|
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
|
// Optionaler Upload in einen (privaten!) Discord-Kanal — Offsite-Kopie
|
||||||
const channelId = getSetting('backup_channel_id');
|
const channelId = getSetting('backup_channel_id');
|
||||||
|
let hoch = false;
|
||||||
if (channelId && client) {
|
if (channelId && client) {
|
||||||
if (sizeBytes <= MAX_UPLOAD_BYTES) {
|
if (sizeBytes <= MAX_UPLOAD_BYTES) {
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
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)`,
|
content: `💾 Backup ${stamp} (${(sizeBytes / 1024).toFixed(0)} KB)`,
|
||||||
files: [new AttachmentBuilder(gzFile)],
|
files: [new AttachmentBuilder(gzFile)],
|
||||||
});
|
});
|
||||||
|
hoch = true;
|
||||||
}
|
}
|
||||||
} else {
|
} 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)`);
|
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());
|
setSetting('last_backup', new Date().toISOString());
|
||||||
console.log(`[backup] ${gzFile} erstellt (${(sizeBytes / 1024).toFixed(0)} KB)`);
|
// Den Fehlerstand mit aufräumen: bliebe er stehen, meldete der Wachhund
|
||||||
return { file: gzFile, sizeBytes };
|
// 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) {
|
export function scheduleBackups(client) {
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
if (getSetting('backup_enabled') === '0') return;
|
if (getSetting('backup_enabled') === '0') return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (now.getHours() !== 3) return;
|
const heuteSchon = (getSetting('last_backup') ?? '').slice(0, 10)
|
||||||
const today = now.toISOString().slice(0, 10);
|
=== now.toISOString().slice(0, 10);
|
||||||
if ((getSetting('last_backup') ?? '').slice(0, 10) === today) return;
|
|
||||||
|
|
||||||
|
if (!heuteSchon && now.getHours() >= 3) {
|
||||||
try {
|
try {
|
||||||
await runBackup(client);
|
await runBackup(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[backup] Fehlgeschlagen:', 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);
|
setInterval(check, CHECK_INTERVAL_MS);
|
||||||
check();
|
check();
|
||||||
|
|||||||
+1
-6
@@ -8,6 +8,7 @@ import {
|
|||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { moduleEnabled } from '../modules.js';
|
import { moduleEnabled } from '../modules.js';
|
||||||
import { brandEmbed } from '../embeds.js';
|
import { brandEmbed } from '../embeds.js';
|
||||||
|
import { dmAdmin } from '../melden.js';
|
||||||
import { tuning, tuningMs, everyTuned } from '../tuning.js';
|
import { tuning, tuningMs, everyTuned } from '../tuning.js';
|
||||||
|
|
||||||
// URL → { fails, down, since }
|
// URL → { fails, down, since }
|
||||||
@@ -24,12 +25,6 @@ export function watchedServices() {
|
|||||||
return listMonitored().filter((s) => /^https?:\/\//.test(s.url));
|
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
|
// ---------------------------------------------------------------- Der Grund
|
||||||
//
|
//
|
||||||
// Bis hierhin stand im Ausfall-Embed nur "HTTP 503". Das sagt, DASS etwas ist,
|
// Bis hierhin stand im Ausfall-Embed nur "HTTP 503". Das sagt, DASS etwas ist,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user