Scraper: stop walking the whole library once a minute for status polls
The reader polled /api/state every 15 s; each call rebuilt the scraper's library summary when it was over 60 s old, reading every chapter.json (on the NAS about 5,300 opens / 16,000 NFS calls and 1.3 s CPU per walk, once a minute, plus 115 KB per poll). The reader now polls GET /api/revision (falls back to /api/state on an older scraper), and the summary is rebuilt after a library change or after 15 minutes. Release-date writes invalidate it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b328ad98fa
commit
03767d5704
5 files changed
+40
-7
No files matched your search
@@ -199,7 +199,8 @@ All responses are JSON. When `SCRAPER_TOKEN` is set, send
|
|||||||
| Method | Path | Body / result |
|
| Method | Path | Body / result |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/health` | Basic liveness |
|
| `GET` | `/health` | Basic liveness |
|
||||||
| `GET` | `/api/state` | Queue, all active source checkpoints, settings, errors, history, adapters |
|
| `GET` | `/api/state` | Queue, all active source checkpoints, settings, errors, history, adapters. The library summary in it is rebuilt in the background after a publish, or when it is 15 minutes old (the walk reads every `chapter.json`) |
|
||||||
|
| `GET` | `/api/revision` | `{ publishedRevision, status, paused }` only; the reader polls this every 15 s |
|
||||||
| `POST` | `/api/queue` | `{url,title?,metadataSearch?,scope?,existingPolicy?,libraryMangaId?,inspect?}` |
|
| `POST` | `/api/queue` | `{url,title?,metadataSearch?,scope?,existingPolicy?,libraryMangaId?,inspect?}` |
|
||||||
| `POST` | `/api/queue/remove` | `{id}`; active downloads cannot be removed |
|
| `POST` | `/api/queue/remove` | `{id}`; active downloads cannot be removed |
|
||||||
| `POST` | `/api/queue/clear-completed` | `{source?}`; remove inactive successful download job rows only; preserve library, history, checkpoints and other statuses |
|
| `POST` | `/api/queue/clear-completed` | `{source?}`; remove inactive successful download job rows only; preserve library, history, checkpoints and other statuses |
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ async function body(req) {
|
|||||||
if (url.pathname === '/health') return json(res, 200, { ok: true, status: service.state.status });
|
if (url.pathname === '/health') return json(res, 200, { ok: true, status: service.state.status });
|
||||||
if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) return json(res, 401, { error: 'Unauthorized' });
|
if (TOKEN && req.headers.authorization !== `Bearer ${TOKEN}`) return json(res, 401, { error: 'Unauthorized' });
|
||||||
if (req.method === 'GET' && url.pathname === '/api/state') return json(res, 200, await service.publicState());
|
if (req.method === 'GET' && url.pathname === '/api/state') return json(res, 200, await service.publicState());
|
||||||
|
if (req.method === 'GET' && url.pathname === '/api/revision') return json(res, 200, service.revision());
|
||||||
if (req.method !== 'POST') return json(res, 404, { error: 'Not found' });
|
if (req.method !== 'POST') return json(res, 404, { error: 'Not found' });
|
||||||
const input = await body(req);
|
const input = await body(req);
|
||||||
if (url.pathname === '/api/queue') return json(res, 200, { item: await service.enqueue(input), state: await service.publicState() });
|
if (url.pathname === '/api/queue') return json(res, 200, { item: await service.enqueue(input), state: await service.publicState() });
|
||||||
|
|||||||
+15
-4
@@ -16,6 +16,7 @@ const { writeJsonAtomic } = require('./lib/json-file');
|
|||||||
const { ReleaseDateSources, refreshMangaReleaseDates } = require('./metadata/release-dates');
|
const { ReleaseDateSources, refreshMangaReleaseDates } = require('./metadata/release-dates');
|
||||||
|
|
||||||
const RATE_KEYS = ['minMs', 'maxMs', 'chapterMinMs', 'chapterMaxMs'];
|
const RATE_KEYS = ['minMs', 'maxMs', 'chapterMinMs', 'chapterMaxMs'];
|
||||||
|
const INVENTORY_SUMMARY_MS = 15 * 60000; // status summary age limit; decisions still scan (libraryInventory)
|
||||||
const FAST_RATE_SETTINGS = {
|
const FAST_RATE_SETTINGS = {
|
||||||
minMs: 1000, maxMs: 4000, chapterMinMs: 5000, chapterMaxMs: 15000,
|
minMs: 1000, maxMs: 4000, chapterMinMs: 5000, chapterMaxMs: 15000,
|
||||||
};
|
};
|
||||||
@@ -401,7 +402,7 @@ class ScraperService {
|
|||||||
} catch (error) { await this.event('warning', `Release dates for ${manga.title} failed: ${error.message}`, { mangaId: manga.id }); }
|
} catch (error) { await this.event('warning', `Release dates for ${manga.title} failed: ${error.message}`, { mangaId: manga.id }); }
|
||||||
}
|
}
|
||||||
// The reader reconciles fully when the revision rises without a visible folder change.
|
// The reader reconciles fully when the revision rises without a visible folder change.
|
||||||
if (written) { this.state.publishedRevision = Number(this.state.publishedRevision || 0) + 1; await this.persist(); }
|
if (written) { this.invalidateInventory(); this.state.publishedRevision = Number(this.state.publishedRevision || 0) + 1; await this.persist(); }
|
||||||
await this.event('info', `Release dates: ${written} chapter${written === 1 ? '' : 's'} dated in ${checked} ongoing series`, { reason, checked, written });
|
await this.event('info', `Release dates: ${written} chapter${written === 1 ? '' : 's'} dated in ${checked} ongoing series`, { reason, checked, written });
|
||||||
return { checked, written };
|
return { checked, written };
|
||||||
}
|
}
|
||||||
@@ -583,6 +584,15 @@ class ScraperService {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inventoryStale() {
|
||||||
|
return this.inventoryCacheGeneration !== this.inventoryGeneration || Date.now() - this.inventoryCachedAt >= INVENTORY_SUMMARY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the reader polls every few seconds: no library walk, no event log read.
|
||||||
|
revision() {
|
||||||
|
return { publishedRevision: this.state.publishedRevision || 0, status: this.state.status, paused: this.state.paused };
|
||||||
|
}
|
||||||
|
|
||||||
async publicState() {
|
async publicState() {
|
||||||
const activeItems = this.state.queue.filter(item => Object.values(this.state.currentIds || {}).includes(item.id));
|
const activeItems = this.state.queue.filter(item => Object.values(this.state.currentIds || {}).includes(item.id));
|
||||||
const active = await Promise.all(activeItems.map(async current => ({
|
const active = await Promise.all(activeItems.map(async current => ({
|
||||||
@@ -593,8 +603,9 @@ class ScraperService {
|
|||||||
const job = active[0]?.job || null;
|
const job = active[0]?.job || null;
|
||||||
// Status and watchdog probes must not wait for thousands of bind-mount reads.
|
// Status and watchdog probes must not wait for thousands of bind-mount reads.
|
||||||
// Share one background scan, serve the previous summary, and surface its age/error.
|
// Share one background scan, serve the previous summary, and surface its age/error.
|
||||||
if (!this.inventoryRefresh && (this.inventoryCacheGeneration !== this.inventoryGeneration || Date.now() - this.inventoryCachedAt >= 60000)
|
// The scraper is the library's only writer and invalidates the summary on every publish, so the age
|
||||||
&& Date.now() - this.inventoryAttemptedAt >= 10000) {
|
// limit only catches outside edits. A walk reads every chapter.json: on a NAS that is ~10,000 opens.
|
||||||
|
if (!this.inventoryRefresh && this.inventoryStale() && Date.now() - this.inventoryAttemptedAt >= 10000) {
|
||||||
this.refreshInventory().catch(() => {});
|
this.refreshInventory().catch(() => {});
|
||||||
}
|
}
|
||||||
const inventory = this.inventoryCache;
|
const inventory = this.inventoryCache;
|
||||||
@@ -609,7 +620,7 @@ class ScraperService {
|
|||||||
updateChecks: Object.values(this.state.updateChecks || {}),
|
updateChecks: Object.values(this.state.updateChecks || {}),
|
||||||
libraryStatus: {
|
libraryStatus: {
|
||||||
ready: !!inventory, refreshing: !!this.inventoryRefresh,
|
ready: !!inventory, refreshing: !!this.inventoryRefresh,
|
||||||
stale: this.inventoryCacheGeneration !== this.inventoryGeneration || Date.now() - this.inventoryCachedAt >= 60000,
|
stale: this.inventoryStale(),
|
||||||
updated: this.inventoryCachedAt ? new Date(this.inventoryCachedAt).toISOString() : null,
|
updated: this.inventoryCachedAt ? new Date(this.inventoryCachedAt).toISOString() : null,
|
||||||
error: this.inventoryError,
|
error: this.inventoryError,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,6 +80,23 @@ test('background inventory failures are visible without breaking controls or lau
|
|||||||
assert.equal(scans, 2);
|
assert.equal(scans, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('status polling walks the library again only after a change or after 15 minutes', async () => {
|
||||||
|
let scans = 0;
|
||||||
|
const service = fixture(async () => { scans++; return inventory('Demo'); });
|
||||||
|
await service.libraryInventory(true);
|
||||||
|
service.inventoryCachedAt = Date.now() - 10 * 60000; service.inventoryAttemptedAt = 0;
|
||||||
|
for (let n = 0; n < 5; n++) await service.publicState();
|
||||||
|
assert.equal(scans, 1);
|
||||||
|
assert.deepEqual(service.revision(), { publishedRevision: 0, status: 'paused', paused: true });
|
||||||
|
assert.equal(scans, 1);
|
||||||
|
service.invalidateInventory();
|
||||||
|
await service.publicState(); await service.inventoryRefresh;
|
||||||
|
assert.equal(scans, 2);
|
||||||
|
service.inventoryCachedAt = Date.now() - 16 * 60000; service.inventoryAttemptedAt = 0;
|
||||||
|
await service.publicState(); await service.inventoryRefresh;
|
||||||
|
assert.equal(scans, 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('dashboard polling reuses a recent snapshot while decisions still force a fresh scan', async () => {
|
test('dashboard polling reuses a recent snapshot while decisions still force a fresh scan', async () => {
|
||||||
let scans = 0;
|
let scans = 0;
|
||||||
const service = fixture(async () => { scans++; return inventory('Demo'); });
|
const service = fixture(async () => { scans++; return inventory('Demo'); });
|
||||||
|
|||||||
@@ -1218,10 +1218,13 @@ if (FULL_SCAN_MINUTES > 0) setInterval(() => {
|
|||||||
|
|
||||||
// A completed scraper publish increments a durable revision. Polling it here keeps the
|
// A completed scraper publish increments a durable revision. Polling it here keeps the
|
||||||
// reader index fresh without exposing an unauthenticated rescan endpoint to the daemon.
|
// reader index fresh without exposing an unauthenticated rescan endpoint to the daemon.
|
||||||
let scraperRevision = null;
|
let scraperRevision = null, revisionRoute = true;
|
||||||
async function pollScraper() {
|
async function pollScraper() {
|
||||||
try {
|
try {
|
||||||
const state = await scraperRequest('/api/state');
|
// /api/revision is a few bytes; the full state is ~100 KB and makes the scraper summarise the library.
|
||||||
|
let state;
|
||||||
|
if (revisionRoute) state = await scraperRequest('/api/revision').catch(error => { if (error.code !== 404) throw error; revisionRoute = false; });
|
||||||
|
if (!revisionRoute) state = await scraperRequest('/api/state'); // a scraper from before /api/revision
|
||||||
const revision = Number(state.publishedRevision || 0);
|
const revision = Number(state.publishedRevision || 0);
|
||||||
if (scraperRevision === null || revision > scraperRevision) {
|
if (scraperRevision === null || revision > scraperRevision) {
|
||||||
const checked = await refreshChanged();
|
const checked = await refreshChanged();
|
||||||
|
|||||||
Reference in new issue
Block a user