Idle connections fetch capture ranges into the corpus
The spool only fetches what workers lease soon, in priority order, and the tail never ends: after a reset the 1,400 capture ranges waited behind it while 34 of 40 connections idled. Up to CORPUS_AHEAD (8) planned capture ranges whose chunks aren't all stored are now targets after everything else, fetched into the corpus only (no spool); a later lease reads them from disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0fafe71b21
commit
7b436f3f77
5 files changed
+106
-3
No files matched your search
+19
-1
@@ -1,4 +1,4 @@
|
|||||||
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { unzip } from 'node:zlib'
|
import { unzip } from 'node:zlib'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
@@ -35,6 +35,24 @@ class Corpus {
|
|||||||
return join(this.rangeDir(range), `${String(index).padStart(4, '0')}-${span.from}-${span.to}.gz`)
|
return join(this.rangeDir(range), `${String(index).padStart(4, '0')}-${span.from}-${span.to}.gz`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether every chunk of the range is stored (its range.json). */
|
||||||
|
async complete(range: CorpusRange): Promise<boolean> {
|
||||||
|
if (!this.dir) return false
|
||||||
|
return access(join(this.rangeDir(range), 'range.json')).then(
|
||||||
|
() => true,
|
||||||
|
() => false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether one chunk is stored. */
|
||||||
|
async has(range: CorpusRange, span: ChunkSpan): Promise<boolean> {
|
||||||
|
if (!this.dir) return false
|
||||||
|
return access(this.path(range, span)).then(
|
||||||
|
() => true,
|
||||||
|
() => false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** A stored chunk, or null. */
|
/** A stored chunk, or null. */
|
||||||
async read(range: CorpusRange, span: ChunkSpan): Promise<Buffer | null> {
|
async read(range: CorpusRange, span: ChunkSpan): Promise<Buffer | null> {
|
||||||
if (!this.dir) return null
|
if (!this.dir) return null
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface Target {
|
|||||||
end: number
|
end: number
|
||||||
/** Its chunks are kept in the corpus (ranges.capture), and read from there when stored. */
|
/** Its chunks are kept in the corpus (ranges.capture), and read from there when stored. */
|
||||||
capture: boolean
|
capture: boolean
|
||||||
|
/** Fetched into the corpus only, not the spool: a capture range no worker leases soon (corpus.ahead). */
|
||||||
|
corpusOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A request's bytes in are sampled this often; STALL_SAMPLES of them make the minute a stall is judged over. */
|
/** A request's bytes in are sampled this often; STALL_SAMPLES of them make the minute a stall is judged over. */
|
||||||
@@ -70,12 +72,16 @@ export class Prefetcher {
|
|||||||
private sampler?: NodeJS.Timeout
|
private sampler?: NodeJS.Timeout
|
||||||
/** Requests in flight: what, since when, on which connection (the report names the oldest). */
|
/** Requests in flight: what, since when, on which connection (the report names the oldest). */
|
||||||
private inFlight = new Set<{ rangeId: number; from: number; to: number; since: number; client: XoverClient }>()
|
private inFlight = new Set<{ rangeId: number; from: number; to: number; since: number; client: XoverClient }>()
|
||||||
|
/** Capture ranges found complete in the corpus (not looked at again). */
|
||||||
|
private stored = new Set<number>()
|
||||||
|
|
||||||
private freshStats() {
|
private freshStats() {
|
||||||
return {
|
return {
|
||||||
chunks: 0,
|
chunks: 0,
|
||||||
/** Chunks read from the corpus instead of fetched. */
|
/** Chunks read from the corpus instead of fetched. */
|
||||||
stored: 0,
|
stored: 0,
|
||||||
|
/** Chunks fetched into the corpus only (corpus.ahead). */
|
||||||
|
captured: 0,
|
||||||
articles: 0,
|
articles: 0,
|
||||||
fetchMs: 0,
|
fetchMs: 0,
|
||||||
hedged: 0,
|
hedged: 0,
|
||||||
@@ -150,7 +156,7 @@ export class Prefetcher {
|
|||||||
}
|
}
|
||||||
const corpusRange = { group: target.group, start: target.start, end: target.end }
|
const corpusRange = { group: target.group, start: target.start, end: target.end }
|
||||||
// A range in the corpus is read from disk (a reset re-indexes it without the news server).
|
// A range in the corpus is read from disk (a reset re-indexes it without the news server).
|
||||||
let data = target.capture ? await Corpus.read(corpusRange, span) : null
|
let data = target.capture && !target.corpusOnly ? await Corpus.read(corpusRange, span) : null
|
||||||
const stored = data !== null
|
const stored = data !== null
|
||||||
try {
|
try {
|
||||||
if (!data) data = target.plain.has(span.from) ? await NntpPool.usePlain(fetch) : await this.fetchHedged(target.id, target.group, span)
|
if (!data) data = target.plain.has(span.from) ? await NntpPool.usePlain(fetch) : await this.fetchHedged(target.id, target.group, span)
|
||||||
@@ -165,6 +171,10 @@ export class Prefetcher {
|
|||||||
this.stats.fetchMs += Date.now() - fetchedAt
|
this.stats.fetchMs += Date.now() - fetchedAt
|
||||||
if (target.capture) await Corpus.write(corpusRange, span, data, target.total)
|
if (target.capture) await Corpus.write(corpusRange, span, data, target.total)
|
||||||
}
|
}
|
||||||
|
if (target.corpusOnly) {
|
||||||
|
this.stats.captured++
|
||||||
|
continue
|
||||||
|
}
|
||||||
const writtenAt = Date.now()
|
const writtenAt = Date.now()
|
||||||
this.writingNow++
|
this.writingNow++
|
||||||
try {
|
try {
|
||||||
@@ -304,7 +314,9 @@ export class Prefetcher {
|
|||||||
`Prefetched ${chunks} chunks in ${seconds.toFixed(0)} s: ${Math.round(articles / seconds).toLocaleString('en')} articles/s, ` +
|
`Prefetched ${chunks} chunks in ${seconds.toFixed(0)} s: ${Math.round(articles / seconds).toLocaleString('en')} articles/s, ` +
|
||||||
`${(fetchMs / chunks / 1000).toFixed(1)} s a chunk; ` +
|
`${(fetchMs / chunks / 1000).toFixed(1)} s a chunk; ` +
|
||||||
`hedged ${hedged} (second won ${hedgeWon}); slow by connection age: ${ages}` +
|
`hedged ${hedged} (second won ${hedgeWon}); slow by connection age: ${ages}` +
|
||||||
(this.stats.stored > 0 ? `; ${this.stats.stored} chunks from the corpus.` : '.')
|
(this.stats.stored > 0 ? `; ${this.stats.stored} chunks from the corpus` : '') +
|
||||||
|
(this.stats.captured > 0 ? `; ${this.stats.captured} fetched into the corpus only` : '') +
|
||||||
|
'.'
|
||||||
)
|
)
|
||||||
const st = this.stats
|
const st = this.stats
|
||||||
const avg = (n: number) => (samples > 0 ? n / samples : 0)
|
const avg = (n: number) => (samples > 0 ? n / samples : 0)
|
||||||
@@ -390,6 +402,35 @@ export class Prefetcher {
|
|||||||
targets.push({ id: Number(row.id), group: row.group, plain: new Set((row.plain_chunks ?? []).map(Number)), missing, total, start, end, capture: row.capture === true })
|
targets.push({ id: Number(row.id), group: row.group, plain: new Set((row.plain_chunks ?? []).map(Number)), missing, total, start, end, capture: row.capture === true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Last, for loops with nothing else to do: capture ranges not stored yet, into the corpus only.
|
||||||
|
for (const target of await this.corpusTargets(rows, new Set(targets.map((t) => t.id)))) targets.push(target)
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to corpus.ahead planned capture ranges, not leased soon, whose chunks aren't all in the corpus. */
|
||||||
|
private async corpusTargets(rows: (q: any) => any, taken: Set<number>): Promise<Target[]> {
|
||||||
|
const { dir, ahead } = coordinatorConfig.corpus
|
||||||
|
if (!dir || ahead <= 0) return []
|
||||||
|
const candidates = await rows(db.query()).where('r.status', 'planned').where('r.capture', true).where('r.spooled_chunks', 0).orderBy('r.id')
|
||||||
|
const targets: Target[] = []
|
||||||
|
for (const row of candidates) {
|
||||||
|
if (targets.length >= ahead) break
|
||||||
|
const id = Number(row.id)
|
||||||
|
if (taken.has(id) || this.stored.has(id)) continue
|
||||||
|
const start = Number(row.start_id)
|
||||||
|
const end = Number(row.end_id)
|
||||||
|
const range = { group: row.group, start, end }
|
||||||
|
if (await Corpus.complete(range)) {
|
||||||
|
this.stored.add(id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const chunks = Spool.chunksOf({ start, end })
|
||||||
|
const missing: ChunkSpan[] = []
|
||||||
|
for (const span of chunks) if (!this.claimed.has(`${id}:${span.from}`) && !(await Corpus.has(range, span))) missing.push(span)
|
||||||
|
if (missing.length > 0) {
|
||||||
|
targets.push({ id, group: row.group, plain: new Set((row.plain_chunks ?? []).map(Number)), missing, total: chunks.length, start, end, capture: true, corpusOnly: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
return targets
|
return targets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,12 @@ export default {
|
|||||||
/** Stored XOVER chunks of ranges marked `capture`, for the grouping sweeps (app/services/corpus.ts). Off when unset. */
|
/** Stored XOVER chunks of ranges marked `capture`, for the grouping sweeps (app/services/corpus.ts). Off when unset. */
|
||||||
corpus: {
|
corpus: {
|
||||||
dir: env.get('CORPUS_DIR'),
|
dir: env.get('CORPUS_DIR'),
|
||||||
|
/**
|
||||||
|
* Capture ranges fetched into the corpus by connections the spool leaves idle, this many at a time: the
|
||||||
|
* spool only fetches what workers lease soon, and the tail comes first, so after a reset the capture
|
||||||
|
* ranges waited behind it for hours (1,400 marked, none stored, 34 of 40 connections idle; LOG.md 2026-10-12 (27)).
|
||||||
|
*/
|
||||||
|
ahead: env.get('CORPUS_AHEAD', 8),
|
||||||
},
|
},
|
||||||
spool: {
|
spool: {
|
||||||
dir: env.get('SPOOL_DIR', app.tmpPath('spool')),
|
dir: env.get('SPOOL_DIR', app.tmpPath('spool')),
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default await Env.create(new URL('../', import.meta.url), {
|
|||||||
LEASE_TTL_SECONDS: Env.schema.number.optional(),
|
LEASE_TTL_SECONDS: Env.schema.number.optional(),
|
||||||
SPOOL_DIR: Env.schema.string.optional(),
|
SPOOL_DIR: Env.schema.string.optional(),
|
||||||
CORPUS_DIR: Env.schema.string.optional(),
|
CORPUS_DIR: Env.schema.string.optional(),
|
||||||
|
CORPUS_AHEAD: Env.schema.number.optional(),
|
||||||
PROBE_PIPELINE: Env.schema.number.optional(),
|
PROBE_PIPELINE: Env.schema.number.optional(),
|
||||||
SPOOL_AHEAD_PER_LEASE: Env.schema.number.optional(),
|
SPOOL_AHEAD_PER_LEASE: Env.schema.number.optional(),
|
||||||
SPOOL_MIN_AHEAD: Env.schema.number.optional(),
|
SPOOL_MIN_AHEAD: Env.schema.number.optional(),
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { test } from '@japa/runner'
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { gzipSync } from 'node:zlib'
|
||||||
|
import coordinatorConfig from '#config/coordinator'
|
||||||
|
import Corpus from '#services/corpus'
|
||||||
|
|
||||||
|
test.group('Corpus', (group) => {
|
||||||
|
let dir: string
|
||||||
|
let before: string | undefined
|
||||||
|
group.each.setup(async () => {
|
||||||
|
dir = await mkdtemp(join(tmpdir(), 'corpus-'))
|
||||||
|
before = coordinatorConfig.corpus.dir
|
||||||
|
coordinatorConfig.corpus.dir = dir
|
||||||
|
})
|
||||||
|
group.each.teardown(async () => {
|
||||||
|
coordinatorConfig.corpus.dir = before
|
||||||
|
await rm(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('knows the chunks it holds, and a range once all are there', async ({ assert }) => {
|
||||||
|
const range = { group: 'alt.binaries.test', start: 1, end: 200_000 }
|
||||||
|
const first = { from: 1, to: 100_000 }
|
||||||
|
const second = { from: 100_001, to: 200_000 }
|
||||||
|
assert.isFalse(await Corpus.has(range, first))
|
||||||
|
await Corpus.write(range, first, gzipSync('1\tsubject\n'), 2)
|
||||||
|
assert.isTrue(await Corpus.has(range, first))
|
||||||
|
assert.isFalse(await Corpus.complete(range))
|
||||||
|
// A reply cut short isn't kept.
|
||||||
|
await Corpus.write(range, second, Buffer.from('not gzip'), 2)
|
||||||
|
assert.isFalse(await Corpus.has(range, second))
|
||||||
|
await Corpus.write(range, second, gzipSync('2\tsubject\n'), 2)
|
||||||
|
assert.isTrue(await Corpus.complete(range))
|
||||||
|
assert.equal((await Corpus.read(range, second))?.length, gzipSync('2\tsubject\n').length)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user