Fold the sentinel into the scraper and drop the sentinel service (BL-0046)
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>
This commit is contained in:
1 parent
88555d4780
commit
b2bb34174c
37 files changed
+163
-311
No files matched your search
@@ -9,7 +9,7 @@ const project = 'mc-qa-' + crypto.randomBytes(3).toString('hex'), port = process
|
||||
const library = path.join(dir, 'library'), data = path.join(dir, 'data'), backupDir = path.join(dir, 'backup'), tokenFile = path.join(dir, 'token');
|
||||
const directionQa = process.argv.includes('--direction-ui');
|
||||
const peerGatewayQa = process.argv.includes('--peer-gateway');
|
||||
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(path.join(data, 'sentinel')); fs.mkdirSync(backupDir);
|
||||
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(backupDir);
|
||||
if (peerGatewayQa) {
|
||||
const peerData = path.join(data, 'peering');
|
||||
const peerUsage = path.join(data, 'peering-usage');
|
||||
@@ -47,7 +47,7 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
|
||||
...(directionQa && index === 0 ? { 'x-reading-direction': { version: 1, value: 'rtl', source: 'fallback' } } : {}),
|
||||
status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
|
||||
}
|
||||
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'), SENTINEL_DATA_PATH: path.join(data, 'sentinel'),
|
||||
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'),
|
||||
SCRAPER_TOKEN_PATH: tokenFile, BACKUP_PATH: backupDir, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
||||
READER_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
||||
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
|
||||
@@ -201,12 +201,12 @@ let keep = false;
|
||||
}
|
||||
const settingsJs = await (await fetch(url + '/settings.js')).text();
|
||||
assert.ok(!settingsJs.includes('Start it with: npm run start:scraper'));
|
||||
await until(async () => { const h = await (await api('/api/admin/health')).json(); return h.sentinel.services?.reader && h.sentinel.services?.scraper; }, 'Internal Sentinel networking failed');
|
||||
await until(async () => (await (await api('/api/admin/scraper')).json()).available, 'Internal scraper networking failed');
|
||||
const mounts = JSON.parse(await docker('config', '--format', 'json'));
|
||||
assert.ok(mounts.services.reader.volumes.find(v => v.target === '/library').read_only);
|
||||
assert.ok(!mounts.services.scraper.ports?.length && !mounts.services.sentinel.ports?.length);
|
||||
const denied = JSON.parse(await docker('exec', '-T', 'reader', 'node', '-e', "Promise.all(['http://scraper:8091/api/state','http://sentinel:8092/api/state'].map(u=>fetch(u).then(r=>r.status))).then(v=>console.log(JSON.stringify(v)))"));
|
||||
assert.deepEqual(denied, [401, 401]);
|
||||
assert.ok(!mounts.services.scraper.ports?.length && !mounts.services.sentinel);
|
||||
const denied = JSON.parse(await docker('exec', '-T', 'reader', 'node', '-e', "fetch('http://scraper:8091/api/state').then(r=>console.log(r.status))"));
|
||||
assert.equal(denied, 401);
|
||||
// Deliberate fixture-only child crash: its supervisor must recover just the reader.
|
||||
const before = JSON.parse(fs.readFileSync(path.join(data, 'runtime-reader.json'))).pid;
|
||||
await docker('exec', '-T', 'reader', 'node', '-e', "const fs=require('fs');process.kill(JSON.parse(fs.readFileSync('/data/runtime-reader.json')).pid,'SIGKILL')");
|
||||
@@ -247,11 +247,11 @@ let keep = false;
|
||||
assert.equal(login.status, 200);
|
||||
await require('./test-direction-ui')(url, login.headers.get('set-cookie').split(';')[0]);
|
||||
}
|
||||
console.log('PASS: isolated Compose auth, collections, read-only reader, private APIs, Sentinel networking and child crash recovery');
|
||||
console.log('PASS: isolated Compose auth, collections, read-only reader, private APIs, scraper networking and child crash recovery');
|
||||
keep = process.argv.includes('--keep');
|
||||
if (keep) {
|
||||
const envFile = path.join(dir, 'qa.env');
|
||||
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SENTINEL_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'BACKUP_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE',
|
||||
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'BACKUP_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE',
|
||||
...(readingGateQa ? ['READING_WINDOWS_JSON', 'READING_TIMEZONE', 'READING_QUIZ_UNLOCK_MINUTES', 'READING_QUIZ_CATALOG_PATH'] : [])]
|
||||
.map(k => `${k}=${k === 'READING_WINDOWS_JSON' ? `'${env[k]}'` : env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
|
||||
console.log(JSON.stringify({ project, url, envFile, fixture: dir }));
|
||||
|
||||
Reference in new issue
Block a user