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:
1 parent
53bfea8d15
commit
a979b5d11c
6 files changed
+60
-10
No files matched your search
@@ -74,6 +74,12 @@ export default class NodeController {
|
|||||||
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.finish(rangeId, nodeId))
|
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) {
|
async release(ctx: HttpContext) {
|
||||||
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.release(rangeId, nodeId))
|
return this.lifecycle(ctx, (rangeId, nodeId) => Leases.release(rangeId, nodeId))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ class Leases {
|
|||||||
await Spool.remove(rangeId)
|
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. */
|
/** A stopping worker gives its range back; the next one resumes it. */
|
||||||
async release(rangeId: number, nodeId: number) {
|
async release(rangeId: number, nodeId: number) {
|
||||||
await this.leased(rangeId, nodeId)
|
await this.leased(rangeId, nodeId)
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ class Spool {
|
|||||||
return readFile(this.path(rangeId, span))
|
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) {
|
async remove(rangeId: number) {
|
||||||
await rm(join(this.dir, String(rangeId)), { recursive: true, force: true })
|
await rm(join(this.dir, String(rangeId)), { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-5
@@ -13,6 +13,8 @@ const gzipAsync = promisify(gzip)
|
|||||||
const RETRIES = 8
|
const RETRIES = 8
|
||||||
|
|
||||||
class LeaseLost extends Error {}
|
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,
|
* 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))
|
posting.catch((error) => (postError ??= error))
|
||||||
}
|
}
|
||||||
let postError: Error | null = null
|
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
|
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
|
reachedEnd = chunk.to >= range.end
|
||||||
if (reachedEnd) indexer.finish()
|
if (reachedEnd) indexer.finish()
|
||||||
if (reachedEnd || chunk.to + 1 - batchFrom >= this.batchArticles) {
|
if (reachedEnd || chunk.to + 1 - batchFrom >= this.batchArticles) {
|
||||||
@@ -146,11 +156,11 @@ export default class EdgeNode extends BaseCommand {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Inflating runs on a thread-pool thread: the next chunk inflates while this one is indexed.
|
// 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))) {
|
for await (const frame of decodeFrames(await api.stream(range.id))) {
|
||||||
if (this.stopping) break
|
if (this.stopping) break
|
||||||
if (isKeepalive(frame)) continue
|
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(() => {})
|
next.text.catch(() => {})
|
||||||
if (pending) await handle(pending)
|
if (pending) await handle(pending)
|
||||||
pending = next
|
pending = next
|
||||||
@@ -172,7 +182,7 @@ export default class EdgeNode extends BaseCommand {
|
|||||||
)
|
)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await posting.catch(() => {})
|
await posting.catch(() => {})
|
||||||
if (error instanceof LeaseLost) {
|
if (error instanceof LeaseLost || error instanceof CorruptChunk) {
|
||||||
this.logger.warning(`Range ${range.id}: ${error.message}`)
|
this.logger.warning(`Range ${range.id}: ${error.message}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -227,6 +237,11 @@ class Api {
|
|||||||
return { ...((await response.json()) as { releases: number; candidates: number }), bytes: body.length }
|
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') {
|
async call(rangeId: number, action: 'finish' | 'release' | 'heartbeat') {
|
||||||
const response = await this.request(`ranges/${rangeId}/${action}`, { method: 'POST', body: '{}' })
|
const response = await this.request(`ranges/${rangeId}/${action}`, { method: 'POST', body: '{}' })
|
||||||
if (!response.ok) throw new Error(`${action}: HTTP ${response.status}`)
|
if (!response.ok) throw new Error(`${action}: HTTP ${response.status}`)
|
||||||
|
|||||||
@@ -233,20 +233,31 @@ export class XoverClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A zlib/gzip stream followed by a "." line: complete once it ends in
|
* A zlib/gzip stream followed by a "." line. The pieces are joined only
|
||||||
* ".\r\n" and inflates. The pieces are joined only then: joining them on
|
* once the data ends in ".\r\n": joining them on every arrival was
|
||||||
* every arrival was quadratic, ~15% of a prefetcher's time (LOG.md 2026-10-09).
|
* 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 }> {
|
private async compressedRaw(wantText: boolean): Promise<{ raw: Buffer; text: string }> {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (this.length > DOT_LINE.length && this.endsWith(DOT_LINE)) {
|
if (this.length > DOT_LINE.length && this.endsWith(DOT_LINE)) {
|
||||||
const all = this.buffer()
|
const all = this.buffer()
|
||||||
const raw = all.subarray(0, all.length - DOT_LINE.length)
|
const raw = all.subarray(0, all.length - DOT_LINE.length)
|
||||||
|
if (!wantText) {
|
||||||
|
this.take(all.length)
|
||||||
|
return { raw, text: '' }
|
||||||
|
}
|
||||||
try {
|
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)
|
const inflated = await unzipAsync(raw)
|
||||||
this.take(all.length)
|
this.take(all.length)
|
||||||
return { raw: Buffer.from(raw), text: wantText ? inflated.toString('utf8') : '' }
|
return { raw, text: inflated.toString('utf8') }
|
||||||
} catch {
|
} catch {
|
||||||
// ".\r\n" inside the compressed data: wait for more.
|
// ".\r\n" inside the compressed data: wait for more.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ router
|
|||||||
router.post('ranges/:id/heartbeat', [controllers.Node, 'heartbeat'])
|
router.post('ranges/:id/heartbeat', [controllers.Node, 'heartbeat'])
|
||||||
router.post('ranges/:id/finish', [controllers.Node, 'finish'])
|
router.post('ranges/:id/finish', [controllers.Node, 'finish'])
|
||||||
router.post('ranges/:id/release', [controllers.Node, 'release'])
|
router.post('ranges/:id/release', [controllers.Node, 'release'])
|
||||||
|
router.post('ranges/:id/corrupt', [controllers.Node, 'corrupt'])
|
||||||
})
|
})
|
||||||
.prefix('node')
|
.prefix('node')
|
||||||
.as('node')
|
.as('node')
|
||||||
|
|||||||
Reference in new issue
Block a user