Files
usenet-indexer/app/services/BackfillService.ts
T
Daan MeijerandClaude Opus 5.5 00f6187b8f
Test and deploy / test (push) Successful in 1m8s
Test and deploy / deploy (push) Successful in 1m37s
Seed leases respect the backfill's disk limits
No new range while the database's NVMe part or the hdd tablespace is over
BACKFILL_MAX_NVME_GB / BACKFILL_MAX_HDD_GB; nodes ask again a minute later.
seed:status says when leasing is paused. The NVMe part was at 47 GB of 50
with a plan for all of a.b.ath queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:30:46 +02:00

113 lines
5.1 KiB
TypeScript

import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import indexerConfig from '#config/indexer'
import NntpService from '#services/NntpService'
import FetchRangeService from '#services/FetchRangeService'
import { articleAtDate } from '#lib/nntp/date_search'
/** Articles XOVERed to find a posting date near an article number. */
const PROBE = 200n
/**
* Header backfill (CONCEPT.md D32). `start` gives a group a cursor from
* where live indexing began back to the article posted N days ago; `tick`
* (every scheduler run) queues low-priority, header-only fetch ranges from
* that cursor downwards, newest first, while few are waiting and the
* database stays within its size limits.
*/
class BackfillService {
/** Sets the group's backfill to reach back `days` days. Returns its range. */
async start(groupName: string, days: number): Promise<{ from: bigint; to: bigint } | null> {
const group = await db.from('groups').where('name', groupName).first()
if (!group) throw new Error(`Unknown group ${groupName}`)
const conn = await NntpService.acquire()
let failed = false
try {
const { first, last } = await NntpService.timed(NntpService.selectGroup(conn, groupName), `GROUP ${groupName}`)
const dateOf = async (n: bigint) => {
const { overviews } = await NntpService.timed<any>(conn.xover(n, n + PROBE), `XOVER ${groupName} ${n}`)
const header = overviews.find(([number, h]: [number | null, any]) => number !== null && h?.date)?.[1]
const date = header ? Date.parse(header.date) : Number.NaN
return Number.isNaN(date) ? null : date
}
const until = await articleAtDate(first, last, Date.now() - days * 24 * 60 * 60 * 1000, dateOf)
// Live indexing began a little before the group was added: continue from there.
const began = await articleAtDate(first, last, new Date(group.created_at).getTime(), dateOf)
const next = group.backfill_next_id !== null && BigInt(group.backfill_next_id) < began ? BigInt(group.backfill_next_id) : began
await db.from('groups').where('id', group.id).update({ backfill_until_id: until.toString(), backfill_next_id: next.toString() })
return next > until ? { from: until, to: next } : null
} catch (error) {
failed = true
throw error
} finally {
NntpService.release(conn, failed)
}
}
/** Stops a group's backfill (queued ranges still run). */
async stop(groupName: string) {
await db.from('groups').where('name', groupName).update({ backfill_until_id: null, backfill_next_id: null })
}
/** Queues the next backfill ranges. Returns how many. */
async tick(): Promise<number> {
const { maxQueued, maxDefaultGb, maxHddGb } = indexerConfig.backfill
const groups = await db
.from('groups')
.where('active', true)
.whereNotNull('backfill_until_id')
.whereRaw('backfill_next_id > backfill_until_id')
.orderBy('id')
if (groups.length === 0) return 0
const room = await this.room(maxDefaultGb, maxHddGb)
if (room !== true) {
logger.warn(`Backfill paused: ${room}.`)
return 0
}
const [{ count }] = await db.from('fetch_ranges').where('backfill', true).where('status', 'queued').count('* as count')
let free = maxQueued - Number(count)
let queued = 0
// Round-robin over the groups, one range each per pass.
while (free > 0 && groups.length > 0) {
for (const group of [...groups]) {
if (free <= 0) break
const next = BigInt(group.backfill_next_id)
const until = BigInt(group.backfill_until_id)
const start = next - indexerConfig.batchSize + 1n > until ? next - indexerConfig.batchSize + 1n : until + 1n
await FetchRangeService.queue(group, start, next, { backfill: true })
group.backfill_next_id = (start - 1n).toString()
await db.from('groups').where('id', group.id).update({ backfill_next_id: group.backfill_next_id })
free--
queued++
if (start - 1n <= until) {
groups.splice(groups.indexOf(group), 1)
logger.info(`${group.name}: backfill fully queued.`)
}
}
}
return queued
}
/** True when both parts of the database are under their limits, else why not. */
/**
* Whether the database has room for more history: the default tablespace
* (NVMe) and tablespace `hdd` against BACKFILL_MAX_NVME_GB/_HDD_GB. Seed
* leases (CONCEPT.md D38) check the same limits.
*/
async room(
maxDefaultGb = indexerConfig.backfill.maxDefaultGb,
maxHddGb = indexerConfig.backfill.maxHddGb
): Promise<true | string> {
const { rows } = await db.rawQuery(
`SELECT spcname, pg_tablespace_size(oid) AS bytes FROM pg_tablespace WHERE spcname IN ('pg_default', 'hdd')`
)
const gb = (name: string) => Number(rows.find((r: any) => r.spcname === name)?.bytes ?? 0) / 1024 ** 3
if (gb('pg_default') > maxDefaultGb) return `database on the NVMe is ${gb('pg_default').toFixed(1)} GB (limit ${maxDefaultGb})`
if (gb('hdd') > maxHddGb) return `files table on the HDD is ${gb('hdd').toFixed(1)} GB (limit ${maxHddGb})`
return true
}
}
export default new BackfillService()