Survive a first full crawl: poll, time out on silence, retry downloads

The first full crawl with the file manager ran 14 minutes, downloading every
file once, and broke in three ways:

- `schulcloud refresh` reported "fetch failed" for a crawl that was
  succeeding: Node's fetch abandons a response without headers after five
  minutes. POST /api/refresh takes wait:false and the CLI polls /api/status;
  refresh_index answers after 50 s and leaves the crawl running, and
  index_status says when a first crawl is under way.
- Downloads were bounded by the 30 s request timeout, which cut 11 MB scans
  off mid-transfer. They now time out on 30 s of silence instead.
- Failures were recorded once and never retried. A download failure is now
  retried on the next crawl while an extraction failure stays final, and PDF
  text containing NUL, which Postgres refuses, is stripped.

On the re-crawl all six failed files succeeded; only two videos above the
mirror cap stay metadata-only, by design. 137 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 20:19:16 +02:00
parent bed3923902
commit 3e44e66dde
11 changed files with 310 additions and 28 deletions

View File

@@ -109,3 +109,61 @@ describe('getCards chunking', () => {
assert.equal(cards.length, 2, 'one card from each of the two chunks');
});
});
describe('download timeouts', () => {
/**
* A real local server, because the timing is the point: a stubbed fetch
* cannot model bytes that keep arriving slowly. requestTimeoutMs is 1000ms.
*/
async function withServer(
handler: (res: import('node:http').ServerResponse) => void,
run: (base: string) => Promise<void>,
) {
const { createServer } = await import('node:http');
const server = createServer((_req, res) => handler(res));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as import('node:net').AddressInfo;
try {
await run(`http://127.0.0.1:${port}`);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
const slowButSteady = (res: import('node:http').ServerResponse) => {
res.writeHead(200, { 'content-type': 'application/pdf' });
let sent = 0;
const tick = setInterval(() => {
res.write(Buffer.alloc(10, 65));
if (++sent === 4) {
clearInterval(tick);
res.end();
}
}, 400);
};
it('lets a download run past the request timeout while bytes keep arriving', async () => {
await withServer(slowButSteady, async (base) => {
// 4 chunks, 400ms apart: 1.6s in total against a 1s timeout, never 1s of silence.
const client = new SchulcloudClient({ ...config, baseUrl: base } as never);
const file = await client.getBytes('/file', 'slow.pdf');
assert.equal(file.bytes.length, 40);
assert.equal(file.truncated, false);
});
});
it('abandons a download that stalls', async () => {
await withServer(
(res) => {
res.writeHead(200, { 'content-type': 'application/pdf' });
res.write(Buffer.alloc(10, 65));
// …and then nothing, well past the 1s idle limit.
},
async (base) => {
const client = new SchulcloudClient({ ...config, baseUrl: base } as never);
await assert.rejects(client.getBytes('/file', 'stalled.pdf'), /no data received for 1s/);
},
);
});
});