import type { FieldDocs } from "./fieldDocs"; // THE DRIVE-HEALTH TIMINGS — `settings.storage.health` — and their defaults. // // The health gate (lib/storageHealth.ts) decides that a drive is not answering // from a handful of numbers: how long one read may take, how often the health // pass looks at the drive, how long that look may take, how many clean looks in // a row clear a stall, and how many reads may wait on one drive at once. They // were constants. Under heavy external-disk churn a stall can be misjudged, and // the ruling (release 15, slice DT) is that the operator then tunes the numbers // on /storage rather than the code. Today's constants are the defaults. // // THE STORED BLOCK HOLDS ONLY WHAT DIFFERS FROM A DEFAULT. Every key is // optional, an absent key is its default, and `sanitizeStorageHealth` drops a // value equal to its default — so a settings.json that never tuned anything has // no `health` key at all, and a default changed in a later release reaches it. // // READS CLAMP, THE FORM REFUSES. A hand-edited value outside its range is // clamped into it on read (the schema's rule: a read never throws); the /storage // form refuses one with a sentence instead, because a save that quietly stored // another number than the one typed reads as a form that did not listen. // // PURE, NO IMPORTS BUT A TYPE: the /storage form (a "use client" file) imports // the defaults, the ranges and the words from here. export type StorageHealthSettings = { budgetMs?: number; passIntervalMs?: number; probeTimeoutMs?: number; clearAfterCleanPasses?: number; inFlightPerLocation?: number; }; // Every timing, resolved: what the health module runs on. export type HealthTimings = Required; export type HealthTimingKey = keyof HealthTimings; // Today's constants (release 15 slice DS), now the defaults. export const HEALTH_TIMING_DEFAULTS: Readonly = Object.freeze({ budgetMs: 3_000, passIntervalMs: 15_000, probeTimeoutMs: 3_000, clearAfterCleanPasses: 2, inFlightPerLocation: 4, }); export const HEALTH_TIMING_BOUNDS: Readonly< Record > = Object.freeze({ budgetMs: { min: 500, max: 60_000 }, passIntervalMs: { min: 5_000, max: 300_000 }, probeTimeoutMs: { min: 500, max: 30_000 }, clearAfterCleanPasses: { min: 1, max: 10 }, // At most HALF the editor's 16 file-access threads (`UV_THREADPOOL_SIZE` in // `start` and the container): one drive that stops answering holds this // many of them, and at 16 it would hold every one — the outage the cap // exists to bound. inFlightPerLocation: { min: 1, max: 8 }, }); // In the order the form shows them. export const HEALTH_TIMING_KEYS: readonly HealthTimingKey[] = [ "budgetMs", "passIntervalMs", "probeTimeoutMs", "clearAfterCleanPasses", "inFlightPerLocation", ]; // A whole number in range, or undefined (not a finite number). export function clampHealthTiming(key: HealthTimingKey, value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; const { min, max } = HEALTH_TIMING_BOUNDS[key]; return Math.min(max, Math.max(min, Math.round(value))); } // Coerce a raw `settings.storage.health` into the stored block: each value a // number clamped into its range, and only when it differs from its default. // Anything else — a string, a key the block does not name — is dropped. export function sanitizeStorageHealth(value: unknown): StorageHealthSettings { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const r = value as Record; const out: StorageHealthSettings = {}; for (const key of HEALTH_TIMING_KEYS) { const n = clampHealthTiming(key, r[key]); if (n !== undefined && n !== HEALTH_TIMING_DEFAULTS[key]) out[key] = n; } return out; } // The stored block with every absent key filled from the defaults. export function resolveHealthTimings(stored?: StorageHealthSettings): HealthTimings { const clean = sanitizeStorageHealth(stored); return { ...HEALTH_TIMING_DEFAULTS, ...clean }; } // THE COUNTERS' SAMPLE SPACING, derived from the pass interval. Two samples of a // disk's request counters closer than this are not compared: a healthy drive can // have a request in flight at two instants a moment apart without completing // one (release 15 DS, "kept at review"). The ruling: min(10 s, interval − 5 s), // so consecutive passes always compare — which is 10 s at the default 15 s, as // it was. Below a 10 s interval that formula falls toward nothing (0 at the 5 s // minimum, where a /storage Refresh just after a pass would compare two samples // taken milliseconds apart), so it is floored at half the interval. export function counterSampleMinimumMs(passIntervalMs: number): number { return Math.min(10_000, Math.max(passIntervalMs - 5_000, Math.round(passIntervalMs / 2))); } // "3 s", "2.5 s", "0.08 s": a millisecond figure in the seconds every surface // uses. export function secondsText(ms: number): string { return `${ms / 1000} s`; } // How many clean answers clear a stall, in words: "once", "twice in a row", // "3 times in a row". export function clearRuleText(n: number): string { return n === 1 ? "once" : n === 2 ? "twice in a row" : `${n} times in a row`; } // What each field means, in the operator's words: the /storage form's hint line. export const HEALTH_TIMING_HINTS: Readonly> = Object.freeze({ budgetMs: "How long one read may take before the drive counts as not answering. Takes effect on the next read.", passIntervalMs: "How often each drive is checked, from its disk's own request counters. A save re-arms the check at once.", probeTimeoutMs: "How long one check may wait for the drive's root where no disk can be named (and for naming the disk).", clearAfterCleanPasses: "How many clean checks in a row it takes before a drive marked not answering is used again.", inFlightPerLocation: "How many reads may be on one drive at once; the rest wait their turn, and are refused if it stops answering. At most 8, half the editor's 16 file-access threads: a drive that stops answering holds this many of them.", }); // SETTINGS.md's `storage.health` table. export const STORAGE_HEALTH_SETTINGS_FIELD_DOCS: FieldDocs = { budgetMs: "How long one read may take before the drive counts as not answering, in ms (default 3000, " + "500–60000). The watchdog's budget (`onDrive`, lib/storageHealth.ts) for one unit of work — a " + "video directory's reads, a page's reads of one video: a unit that has not answered by then is " + "refused, and marks its location not answering unless the disk's request counters show it still " + "completing others (slow, not stalled). A read waiting for a slot is refused when nothing on the " + "drive has returned for this long plus a quarter of it (at most 250 ms). Takes effect on the next " + "read after a save.", passIntervalMs: "How often the health pass reads each location's disk counters, in ms (default 15000, " + "5000–300000). A stall that starts between two passes is seen by the next, or at once by a page's " + "read. A save on /storage re-arms the pass's timer at once; a hand edit, at the next pass. Two " + "counter samples are compared only when at least min(10 s, this − 5 s) apart, a spacing never " + "less than half of this.", probeTimeoutMs: "How long the health pass waits, in ms (default 3000, 500–30000), for the child `stat` of a root " + "where no disk can be named (a timeout counts as not answering) and for the `findmnt` that names a " + "root's disk (a timeout names none that pass). Takes effect on the next pass.", clearAfterCleanPasses: "How many clean answers in a row clear a location marked not answering (default 2, 1–10). Each " + "health pass is one answer, and so is a Refresh on /storage; a miss in between starts the count " + "again. Takes effect on the next answer.", inFlightPerLocation: "How many reads through the watchdog may be on one location's drive at once (default 4, 1–8); the " + "rest wait in the editor's own queue, so a stall mid-walk holds this many of Node's threads, not " + "all of them. At most 8, half of `UV_THREADPOOL_SIZE` (16 in the editor's start script and the " + "container), so one drive that stops answering cannot hold every thread. Takes effect on the next " + "read.", };