Prober: no claims under PROBE_MIN_FREE_GB free on the NVMe
Test and deploy / test (push) Successful in 52s
Test and deploy / edge (push) Successful in 2m5s
Test and deploy / deploy (push) Successful in 1m1s

At ~200 articles/s what the prober stores (inspections, par2 file lists,
the WAL) took ~0.8-1.1 GB an hour of homelab's root, and nothing stopped it
at the 10 GB floor. Below 12 GB free it claims nothing (resumed 1 GB above):
the lanes go dry and its share shrinks to its minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-18 17:38:37 +02:00
1 parent de7c758206
commit 9fb36d217c
6 files changed
+73

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
import { statfs } from 'node:fs/promises'
import logger from '@adonisjs/core/services/logger'
/** How often free space is measured. */
const CHECK_MS = 60_000
/** Resumed only this much above the floor: not paused and resumed at every flush. */
const MARGIN_GB = 1
/**
* Free space on the filesystem holding `path`, against a floor: low below it, again fine MARGIN_GB above it. In a
* container `/` is an overlay on the host's Docker root, which on homelab is the NVMe the catalog and the WAL are on.
* The prober filled it ~0.8–1.1 GB an hour at ~200 articles/s, and nothing stopped it at the 10 GB floor (LOG.md
* 2026-10-18 (77)). Off when the floor is 0.
*/
export class DiskSpace {
private checkedAt = 0
private low = false
constructor(
private what: string,
private path: string,
private floorGb: number,
private free: (path: string) => Promise<number> = async (p) => {
const s = await statfs(p)
return s.bavail * s.bsize
},
private checkMs = CHECK_MS
) {}
async short(): Promise<boolean> {
if (!this.floorGb) return false
if (Date.now() - this.checkedAt < this.checkMs) return this.low
this.checkedAt = Date.now()
const gb = (await this.free(this.path)) / 1e9
const was = this.low
this.low = gb < this.floorGb + (was ? MARGIN_GB : 0)
if (this.low !== was) logger.info(`${gb.toFixed(1)} GB free (floor ${this.floorGb} GB): ${this.what} ${this.low ? 'paused' : 'resumed'}.`)
return this.low
}
}
+5
View File
@@ -7,6 +7,7 @@ import { XoverClient } from '#lib/nntp/index'
import { YencError } from '#lib/yenc/index'
import { inspect, innerName, PROBE_VERSION, type Inspection } from '#lib/probe/index'
import { NntpShare } from '#services/nntp_shares'
import { DiskSpace } from '#services/disk_space'
interface Candidate {
id: number
@@ -52,6 +53,8 @@ export class Prober {
private running = 0
/** The last lane to look found nothing to claim: the prober wants its minimum only. */
private dry = false
/** What it stores (inspections, par2 file lists, the WAL) fills the NVMe: no claims below PROBE_MIN_FREE_GB. */
private space = new DiskSpace('probing', '/', coordinatorConfig.probe.minFreeGb)
private share = new NntpShare('prober', coordinatorConfig.nntpShares.proberPriority, coordinatorConfig.probe.minConnections, coordinatorConfig.probe.connections)
/** Per minute: what was read, and where the lanes' time went (ms summed over lanes; flushes ms on their own). */
@@ -201,6 +204,8 @@ export class Prober {
}
private async claimTiers() {
// The queue empties, the lanes go dry and the share shrinks to its minimum until there's room again.
if (await this.space.short()) return
for (const { kind, unreadable } of TIERS) {
const want = this.batch - this.queue.length
if (want <= 0) return
+2
View File
@@ -90,6 +90,8 @@ export default {
connections: env.get('PROBE_CONNECTIONS', 6),
/** Of them, always (its share's minimum; nntpShares). */
minConnections: env.get('PROBE_MIN_CONNECTIONS', 2),
/** No claims while the filesystem under `/` (the NVMe, in the container) has less free (app/services/disk_space.ts). 0: off. */
minFreeGb: env.get('PROBE_MIN_FREE_GB', 0),
/** Candidates claimed at a time. */
batch: 400,
/** BODY commands in flight per connection (pipelined; XoverClient.bodies). */
+3
View File
@@ -130,6 +130,9 @@ services:
environment:
<<: *app-env
PROBE_CONNECTIONS: ${PROBE_CONNECTIONS:-8}
# No claims under this much free on the NVMe (its overlay root): 2 GB above the 10 GB floor, for what the
# tail, the merger and the judge write meanwhile.
PROBE_MIN_FREE_GB: ${PROBE_MIN_FREE_GB:-12}
# The par2 merge: releases whose files one par2 set describes (what the prober read) become one. No NNTP.
merger:
+1
View File
@@ -78,6 +78,7 @@ export default await Env.create(new URL('../', import.meta.url), {
PREFETCH_PER_RANGE: Env.schema.number.optional(),
PROBE_CONNECTIONS: Env.schema.number.optional(),
PROBE_MIN_CONNECTIONS: Env.schema.number.optional(),
PROBE_MIN_FREE_GB: Env.schema.number.optional(),
HTTP_ROUTES: Env.schema.enum.optional(['all', 'node', 'public'] as const),
PREFETCH_PER_GROUP: Env.schema.number.optional(),
})
+22
View File
@@ -0,0 +1,22 @@
import { test } from '@japa/runner'
import { DiskSpace } from '#services/disk_space'
test.group('DiskSpace', () => {
test('short below the floor, fine again a margin above it; off without a floor', async ({ assert }) => {
let gb = 15
const space = new DiskSpace('probing', '/', 12, async () => gb * 1e9, 0)
assert.isFalse(await space.short())
gb = 11.5
assert.isTrue(await space.short())
// Back over the floor, not yet over the margin: still short.
gb = 12.5
assert.isTrue(await space.short())
gb = 13.1
assert.isFalse(await space.short())
assert.isFalse(await new DiskSpace('probing', '/', 0, async () => 0, 0).short())
})
test('measures a real filesystem', async ({ assert }) => {
assert.isFalse(await new DiskSpace('probing', '/', 0.001).short())
})
})