Rebuild: the coordinator spools replies without inflating them

On homelab (1 Gbit, 20 connections, ~630k articles/s) the coordinator
used 1.3-1.7 cores, mostly GC of the text it inflated only to know a
compressed reply was complete, plus a copy of every reply. A raw reply
is now complete when it ends in ".\r\n" (no copy): 0.4-0.5 cores at
the same rate.

".\r\n" can also end a network read inside the compressed data (~1 in
16M reads, a few dozen times over the whole target). Such a chunk
doesn't inflate on the worker: it reports it (POST ranges/:id/corrupt),
the spooled chunk is removed and the range goes back to the queue; the
prefetcher fetches it again. The rest of that reply breaks the
connection's next command, so the connection is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-09 07:40:06 +02:00
1 parent 53bfea8d15
commit a979b5d11c
6 files changed
+60 -10

No files matched your search

+6
View File
@@ -74,6 +74,12 @@ export default class NodeController {
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.finish(rangeId, nodeId))
}
/** POST /ranges/:id/corrupt { from }: a chunk that doesn't inflate; the range goes back. */
async corrupt(ctx: HttpContext) {
const from = Number(ctx.request.input('from'))
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.corrupt(rangeId, nodeId, from))
}
async release(ctx: HttpContext) {
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.release(rangeId, nodeId))
}
+12
View File
@@ -58,6 +58,18 @@ class Leases {
await Spool.remove(rangeId)
}
/**
* A worker got a chunk that doesn't inflate: the spooled chunk goes and
* the range back to the queue (the prefetcher fetches leased ranges'
* missing chunks again; the next worker resumes at the range's resume point).
*/
async corrupt(rangeId: number, nodeId: number, from: number) {
const range = await this.leased(rangeId, nodeId)
const span = Spool.chunksOf({ start: Number(range.start_id), end: Number(range.end_id) }).find((s) => s.from === from)
if (span) await Spool.removeChunk(rangeId, span)
await db.from('ranges').where('id', rangeId).update({ status: 'planned', node_id: null })
}
/** A stopping worker gives its range back; the next one resumes it. */
async release(rangeId: number, nodeId: number) {
await this.leased(rangeId, nodeId)
+5
View File
@@ -53,6 +53,11 @@ class Spool {
return readFile(this.path(rangeId, span))
}
/** One chunk, e.g. one that doesn't inflate (a false end of reply; see XoverClient.compressedRaw). */
async removeChunk(rangeId: number, span: ChunkSpan) {
await rm(this.path(rangeId, span), { force: true })
}
async remove(rangeId: number) {
await rm(join(this.dir, String(rangeId)), { recursive: true, force: true })
}
+20 -5
View File
@@ -13,6 +13,8 @@ const gzipAsync = promisify(gzip)
const RETRIES = 8
class LeaseLost extends Error {}
/** A spooled chunk that doesn't inflate (reported; the coordinator fetches it again). */
class CorruptChunk extends Error {}
/**
* An edge worker (REBUILD.md, part 2): leases a range from the coordinator,
@@ -134,9 +136,17 @@ export default class EdgeNode extends BaseCommand {
posting.catch((error) => (postError ??= error))
}
let postError: Error | null = null
const handle = async (chunk: { to: number; text: Promise<Buffer> }) => {
const handle = async (chunk: { from: number; to: number; text: Promise<Buffer> }) => {
if (postError) throw postError
indexer.addText((await chunk.text).toString('utf8'), chunk.to + 1)
let text: Buffer
try {
text = await chunk.text
} catch (error: any) {
await posting.catch(() => {})
await api.corrupt(range.id, chunk.from)
throw new CorruptChunk(`chunk ${chunk.from}–${chunk.to} doesn't inflate (${error.message}); reported, the range goes back`)
}
indexer.addText(text.toString('utf8'), chunk.to + 1)
reachedEnd = chunk.to >= range.end
if (reachedEnd) indexer.finish()
if (reachedEnd || chunk.to + 1 - batchFrom >= this.batchArticles) {
@@ -146,11 +156,11 @@ export default class EdgeNode extends BaseCommand {
}
try {
// Inflating runs on a thread-pool thread: the next chunk inflates while this one is indexed.
let pending: { to: number; text: Promise<Buffer> } | null = null
let pending: { from: number; to: number; text: Promise<Buffer> } | null = null
for await (const frame of decodeFrames(await api.stream(range.id))) {
if (this.stopping) break
if (isKeepalive(frame)) continue
const next = { to: frame.to, text: unzipAsync(frame.data) }
const next = { from: frame.from, to: frame.to, text: unzipAsync(frame.data) }
next.text.catch(() => {})
if (pending) await handle(pending)
pending = next
@@ -172,7 +182,7 @@ export default class EdgeNode extends BaseCommand {
)
} catch (error: any) {
await posting.catch(() => {})
if (error instanceof LeaseLost) {
if (error instanceof LeaseLost || error instanceof CorruptChunk) {
this.logger.warning(`Range ${range.id}: ${error.message}`)
return
}
@@ -227,6 +237,11 @@ class Api {
return { ...((await response.json()) as { releases: number; candidates: number }), bytes: body.length }
}
async corrupt(rangeId: number, from: number) {
const response = await this.request(`ranges/${rangeId}/corrupt`, { method: 'POST', body: JSON.stringify({ from }) })
if (!response.ok) throw new Error(`corrupt: HTTP ${response.status}`)
}
async call(rangeId: number, action: 'finish' | 'release' | 'heartbeat') {
const response = await this.request(`ranges/${rangeId}/${action}`, { method: 'POST', body: '{}' })
if (!response.ok) throw new Error(`${action}: HTTP ${response.status}`)
+16 -5
View File
@@ -233,20 +233,31 @@ export class XoverClient {
}
/**
* A zlib/gzip stream followed by a "." line: complete once it ends in
* ".\r\n" and inflates. The pieces are joined only then: joining them on
* every arrival was quadratic, ~15% of a prefetcher's time (LOG.md 2026-10-09).
* A zlib/gzip stream followed by a "." line. The pieces are joined only
* once the data ends in ".\r\n": joining them on every arrival was
* quadratic, ~15% of a prefetcher's time (LOG.md 2026-10-09).
*
* Raw (the coordinator's spool) it is complete then: inflating every reply
* to be sure cost the coordinator ~1.5 cores at ~630 k articles/s (mostly
* GC of the inflated text). ".\r\n" can also end a network read inside
* the compressed data (~1 in 16 M reads): that chunk doesn't inflate on
* the worker, which reports it and the chunk is fetched again (REBUILD.md,
* prefetch), and the rest of the reply breaks this connection's next
* command. With the text wanted, it must inflate.
*/
private async compressedRaw(wantText: boolean): Promise<{ raw: Buffer; text: string }> {
while (true) {
if (this.length > DOT_LINE.length && this.endsWith(DOT_LINE)) {
const all = this.buffer()
const raw = all.subarray(0, all.length - DOT_LINE.length)
if (!wantText) {
this.take(all.length)
return { raw, text: '' }
}
try {
// On a thread-pool thread: a chunk's ~25 MB of text took the coordinator's event loop ~60 ms.
const inflated = await unzipAsync(raw)
this.take(all.length)
return { raw: Buffer.from(raw), text: wantText ? inflated.toString('utf8') : '' }
return { raw, text: inflated.toString('utf8') }
} catch {
// ".\r\n" inside the compressed data: wait for more.
}
+1
View File
@@ -51,6 +51,7 @@ router
router.post('ranges/:id/heartbeat', [controllers.Node, 'heartbeat'])
router.post('ranges/:id/finish', [controllers.Node, 'finish'])
router.post('ranges/:id/release', [controllers.Node, 'release'])
router.post('ranges/:id/corrupt', [controllers.Node, 'corrupt'])
})
.prefix('node')
.as('node')