The stall watchdog now runs inside the scraper (ScraperService.checkStalls, every 30 s from the work loop): same rule as before, one checkpoint-safe restart, quarantine on the second stall, missing checkpoint is no evidence, and paused time no longer counts. Stalls are recorded as scraper incidents (failure.kind 'stall'). The private /api/recovery endpoint is gone. Dropped with the service: reader/scraper-unreachable incidents (supervisor and Docker handle crashes; the health page shows "Scraper: unreachable"), duplicate job-failure incidents and the unused /metrics endpoint. The stack is now reader + scraper (+ optional peer-gateway). The supervisor test moved to runtime/test (npm run test:runtime stays). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
29 lines
2.1 KiB
JavaScript
29 lines
2.1 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
// Targeted recovery snapshot, not a library backup. Caller must prove all writers
|
|
// are stopped first. Never run this concurrently with the reader or scraper.
|
|
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
|
if (process.argv[2] !== '--writers-stopped') throw new Error('First verify that every writer is stopped, then pass --writers-stopped');
|
|
const root = path.resolve(__dirname, '..'), library = path.join(root, 'library'), data = path.join(root, 'data');
|
|
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
const backup = path.join(data, 'scraper', 'backups', stamp + '-compose-cutover');
|
|
fs.mkdirSync(backup, { recursive: true });
|
|
const files = [];
|
|
function copy(source, relative) {
|
|
if (!fs.existsSync(source)) return;
|
|
if (fs.statSync(source).isDirectory()) {
|
|
for (const name of fs.readdirSync(source)) copy(path.join(source, name), path.join(relative, name));
|
|
return;
|
|
}
|
|
const target = path.join(backup, relative); fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.copyFileSync(source, target);
|
|
files.push({ path: relative, bytes: fs.statSync(source).size, sha256: crypto.createHash('sha256').update(fs.readFileSync(source)).digest('hex') });
|
|
}
|
|
for (const name of ['db.json', 'scan-cache.json', 'catalog.json', 'collection-covers', 'runtime-reader.json']) copy(path.join(data, name), path.join('data', name));
|
|
for (const name of ['queue.json', 'runtime.json', 'daemon.lock', 'supervisor.lock']) copy(path.join(data, 'scraper', name), path.join('data', 'scraper', name));
|
|
for (const name of fs.readdirSync(library)) {
|
|
copy(path.join(library, name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'), path.join('library', name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'));
|
|
}
|
|
fs.writeFileSync(path.join(backup, 'snapshot.json'), JSON.stringify({ created: new Date().toISOString(), kind: 'targeted-state-not-image-backup', files }, null, 2));
|
|
console.log(JSON.stringify({ backup, files: files.length, imageFilesCopied: false }));
|