Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/routing/history/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,17 +195,18 @@ function readStoredMetaField(dbHandle: Database, key: string): string {
return metaValue(dbHandle, key) ?? "";
}

function sourceIdentityMatches(dbHandle: Database, revision: UsageLogRevision | null): boolean {
export function sourceIdentityMatches(dbHandle: Database, revision: UsageLogRevision | null): boolean {
const stored = readIndexedMeta(dbHandle);
if (revision === null) return stored.sourceSize === 0;
const storedPath = readStoredMetaField(dbHandle, HISTORY_META_KEYS.sourcePath);
if (!storedPath) return false;
const storedDev = Number(readStoredMetaField(dbHandle, HISTORY_META_KEYS.sourceDev));
const storedIno = Number(readStoredMetaField(dbHandle, HISTORY_META_KEYS.sourceIno));
// Compared as exact decimal strings; see UsageLogRevision for why ids are not Numbers.
const storedDev = readStoredMetaField(dbHandle, HISTORY_META_KEYS.sourceDev);
const storedIno = readStoredMetaField(dbHandle, HISTORY_META_KEYS.sourceIno);
const storedBirthtimeMs = Number(readStoredMetaField(dbHandle, HISTORY_META_KEYS.sourceBirthtimeMs));
return storedPath === revision.path
&& storedDev === Number(revision.dev)
&& storedIno === Number(revision.ino)
&& storedDev === revision.dev
&& storedIno === revision.ino
&& storedBirthtimeMs === Number(revision.birthtimeMs);
}

Expand Down
8 changes: 6 additions & 2 deletions src/routing/history/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Database } from "bun:sqlite";
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
import {
encodePersistedRequestedModel,
exactFileIdentity,
type PersistedUsageEntry,
type UsageLogRevision,
} from "../../usage/log";
Expand Down Expand Up @@ -177,8 +178,11 @@ export function ingestSourceTail(
fd = openSync(path, "r");
const stat = fstatSync(fd);
const expected = options.expected;
if (expected && (Number(stat.dev) !== Number(expected.dev) || Number(stat.ino) !== Number(expected.ino))) {
throw new RequestHistoryIndexStaleError("usage log changed before indexing");
if (expected) {
const opened = exactFileIdentity(fd);
if (opened.dev !== expected.dev || opened.ino !== expected.ino) {
throw new RequestHistoryIndexStaleError("usage log changed before indexing");
}
}
const size = options.endOffset === undefined
? Number(stat.size)
Expand Down
3 changes: 2 additions & 1 deletion src/usage/ledger-retention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { closeSync, fstatSync, openSync, readSync, writeSync } from "node:fs";
import { atomicWriteFileStreamed } from "../config/atomic-write";
import {
currentUsageLogRevision,
exactFileIdentity,
usageLogIdentityKey,
usageLogPath,
usageLogRevisionKey,
Expand Down Expand Up @@ -127,7 +128,7 @@ export function enforceUsageLedgerSizeLimit(
let sourceClosed = false;
try {
const opened = fstatSync(sourceFd);
if (Number(opened.size) !== captured.size || Number(opened.ino) !== captured.ino) {
if (Number(opened.size) !== captured.size || exactFileIdentity(sourceFd).ino !== captured.ino) {
return { kind: "deferred", currentBytes: captured.size, reason: "revision-changed" };
}
const start = firstRowBoundary(sourceFd, Math.max(0, captured.size - target), captured.size);
Expand Down
11 changes: 6 additions & 5 deletions src/usage/ledger-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
import {
currentUsageLogRevision,
exactFileIdentity,
normalizePersistedUsageRow,
usageLogIdentityKey,
usageLogPath,
Expand Down Expand Up @@ -63,13 +64,13 @@ export class UsageLedgerRebuildRequiredError extends Error {

function revisionFromStat(
path: string,
fd: number,
stat: ReturnType<typeof fstatSync>,
): UsageLogRevision {
if (!stat.isFile()) throw new Error("usage log is not a regular file");
return {
path,
dev: Number(stat.dev),
ino: Number(stat.ino),
...exactFileIdentity(fd),
birthtimeMs: Number(stat.birthtimeMs),
size: Number(stat.size),
mtimeMs: Number(stat.mtimeMs),
Expand Down Expand Up @@ -276,7 +277,7 @@ export async function scanUsageLedgerCooperatively(
let pendingLineBytes = 0;

try {
const openedRevision = revisionFromStat(path, fstatSync(fd));
const openedRevision = revisionFromStat(path, fd, fstatSync(fd));
const scanEnd = openedRevision.size;
const openedIdentityKey = usageLogIdentityKey(openedRevision);
if (options.expectedIdentityKey && options.expectedIdentityKey !== openedIdentityKey) {
Expand Down Expand Up @@ -391,7 +392,7 @@ export async function scanUsageLedgerCooperatively(
}

throwIfAborted(options.signal);
const endingRevision = revisionFromStat(path, fstatSync(fd));
const endingRevision = revisionFromStat(path, fd, fstatSync(fd));
if (usageLogIdentityKey(endingRevision) !== openedIdentityKey) {
throw rebuildRequired("identity_mismatch");
}
Expand All @@ -417,7 +418,7 @@ export async function scanUsageLedgerCooperatively(
line,
options.signal,
);
const verifiedFdRevision = revisionFromStat(path, fstatSync(fd));
const verifiedFdRevision = revisionFromStat(path, fd, fstatSync(fd));
const verifiedPathRevision = currentUsageLogRevision();
if (capturedDigest !== verifiedDigest) throw rebuildRequired("content_changed");
if (!verifiedPathRevision
Expand Down
25 changes: 17 additions & 8 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1163,8 +1163,12 @@ export function appendUsageEntry(entry: PersistedUsageEntry): void {

export type UsageLogRevision = {
path: string;
dev: number;
ino: number;
/**
* Exact decimal device and file id. NTFS file ids carry a sequence number in their
* high bits and routinely exceed 2^53, so a Number would let two files compare equal.
*/
dev: string;
Comment thread
lidge-jun marked this conversation as resolved.
ino: string;
birthtimeMs: number;
size: number;
mtimeMs: number;
Expand Down Expand Up @@ -1367,12 +1371,17 @@ function readExactly(fd: number, length: number, position: number): Buffer | nul
return output;
}

function usageLogRevision(path: string, stat: ReturnType<typeof fstatSync>): UsageLogRevision {
/** Exact dev/ino of an open file, read through bigint stats so large ids keep every bit. */
export function exactFileIdentity(fd: number): { dev: string; ino: string } {
const exact = fstatSync(fd, { bigint: true });
return { dev: exact.dev.toString(), ino: exact.ino.toString() };
}

function usageLogRevision(path: string, fd: number, stat: ReturnType<typeof fstatSync>): UsageLogRevision {
if (!stat.isFile()) throw new Error("usage log is not a regular file");
return {
path,
dev: Number(stat.dev),
ino: Number(stat.ino),
...exactFileIdentity(fd),
birthtimeMs: Number(stat.birthtimeMs),
size: Number(stat.size),
mtimeMs: Number(stat.mtimeMs),
Expand Down Expand Up @@ -1405,7 +1414,7 @@ export function currentUsageLogRevision(): UsageLogRevision | null {
let fd: number | undefined;
try {
fd = openSync(path, "r");
return usageLogRevision(path, fstatSync(fd));
return usageLogRevision(path, fd, fstatSync(fd));
} finally {
if (fd !== undefined) closeSync(fd);
}
Expand Down Expand Up @@ -1530,7 +1539,7 @@ async function readUsageEntriesFullCooperatively(
if (prefixDigest === null) throw new Error("usage log changed while it was being read");
return {
entries: parsed.entries,
revision: usageLogRevision(path, stat),
revision: usageLogRevision(path, fd, stat),
truncatedPrefixBytes,
entriesTruncated: parsed.entriesDropped > 0,
entriesDropped: parsed.entriesDropped,
Expand Down Expand Up @@ -1562,7 +1571,7 @@ async function readUsageEntriesIncrementally(
try {
fd = openSync(path, "r");
const stat = fstatSync(fd);
const revision = usageLogRevision(path, stat);
const revision = usageLogRevision(path, fd, stat);
if (usageLogIdentityKey(revision) !== retained.identityKey) return null;
const size = Number(stat.size);
// A shrink means truncation or replacement-in-place; the retained rows may no
Expand Down
2 changes: 1 addition & 1 deletion structure/dashboard-and-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ inheriting its bounds, so deleting a ledger row removes it from the grouping.
whole rows byte for byte through the shared atomic writer, refuses the rename unless the source is
the exact revision that was copied, and then discards the Logs ring, the retained aggregates and the
request-history index so no surface serves rows the ledger no longer has. That index (`src/routing/history/`) rebuilds a ledger of at least `REQUEST_HISTORY_WORKER_REBUILD_MIN_BYTES` (1 MiB) on a worker thread (#6990): `rebuild-worker.ts`, driven by `rebuild-job.ts`, ingests the ledger snapshot the refresh recorded into a private `<index>.rebuild-<pid>-<n>` file through the same `ingest.ts` the inline path uses, switches it to WAL and closes it; the serving thread then closes the old index and renames the build over it. Async readers wait for the build; the routing-time sync entry never does and throws `RequestHistoryIndexBusyError`, which health scoring reads as unknown evidence. A close discards the build, rejects waiting readers with `RequestHistoryIndexClosedError`, and removes the build files only after the worker thread exits. For 30 s after a worker build fails (other than on a replaced ledger), async readers and manual rebuilds rebuild inline instead, while routing-time reads keep reporting busy.
`src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled
Ledger revisions in `src/usage/log.ts` capture device/inode identifiers from bigint descriptor stats as exact decimal strings; history metadata, ingestion, cooperative scans and retention compare them without narrowing to Numbers, preserving distinct NTFS identifiers above 2^53. An older index containing a rounded identifier fails the identity check and rebuilds once. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled
grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. Each scope also aggregates end-to-end output throughput (#6309): measured output tokens and wall-clock `durationMs` are summed over attempts reporting both, exposing `throughputTokensPerSec` — a token-sum-over-duration-sum, never a mean of per-request rates. `throughputSamples` counts qualifying attempts (legacy rows without attempts contribute one); nonpositive or nonfinite tokens/timing are excluded. The Usage summary and Models/Providers tables display the rate and sample basis, or unavailable when none qualify.
All three throughput displays use the same finite-number guard as their tooltip; malformed or nonfinite rates are unavailable rather than formatted.
The management route scans the ledger from its beginning in fixed 1 MiB chunks on a
Expand Down
62 changes: 62 additions & 0 deletions tests/usage/request-history-index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import {
appendFileSync,
existsSync,
Expand All @@ -15,6 +16,7 @@ import { handleManagementAPI } from "../../src/server/management-api";
import { ManagementRequest } from "../helpers/management-auth";
import {
appendUsageEntry,
currentUsageLogRevision,
normalizeUsageEntryForTest,
resetUsageReadCacheForTests,
usageLogPath,
Expand All @@ -25,11 +27,13 @@ import {
queryRequestHistory,
rebuildRequestHistoryIndex,
requestHistoryRowById,
sourceIdentityMatches,
REQUEST_HISTORY_MAX_RECORD_BYTES,
REQUEST_HISTORY_MAX_PAGE_SIZE,
REQUEST_HISTORY_READ_CHUNK_BYTES,
} from "../../src/routing/history/indexer";
import { InvalidCursorError } from "../../src/routing/history/cursor";
import { metaValue, recordSourceMeta, resetAndCreateSchema, setMeta } from "../../src/routing/history/ingest";
import { HISTORY_DB_FILENAME, HISTORY_SCHEMA_VERSION } from "../../src/routing/history/schema";
import { getConfigDir } from "../../src/config";
import { flushConfigDirHardeningForTests } from "../../src/config/paths";
Expand Down Expand Up @@ -103,6 +107,64 @@ async function apiGet(path: string): Promise<Response> {
}

describe("request-history index (RI-02)", () => {
test("SQLite source identities distinguish adjacent device and inode ids above 2^53", () => {
appendUsageEntry(entry("exact-id", 1234, "a", "m1"));
const revision = currentUsageLogRevision()!;
const low = "9007199254740992";
const high = "9007199254740993";
expect(Number(low)).toBe(Number(high));
const db = new Database(":memory:");
try {
resetAndCreateSchema(db);
for (const field of ["dev", "ino"] as const) {
const exact = { ...revision, [field]: high };
recordSourceMeta(db, exact);
expect(metaValue(db, `source_${field}`)).toBe(high);
expect(sourceIdentityMatches(db, exact)).toBe(true);
expect(sourceIdentityMatches(db, { ...exact, [field]: low })).toBe(false);
// The old Number-based writer rounded the high id to its neighbour.
setMeta(db, `source_${field}`, Number(high));
expect(sourceIdentityMatches(db, exact)).toBe(false);
}
} finally {
db.close();
}
});

test("legacy rounded source metadata rebuilds once and then reuses the corrected index", async () => {
appendUsageEntry(entry("legacy-id", 1234, "a", "m1"));
await queryRequestHistory({}, undefined, 10);
closeRequestHistoryIndex();
const dbFile = join(getConfigDir(), HISTORY_DB_FILENAME);
let db = new Database(dbFile);
try {
setMeta(db, "source_ino", Number("9007199254740993"));
} finally {
db.close();
}
const page = await queryRequestHistory({}, undefined, 10);
expect(page.rows.map(row => row.requestId)).toEqual(["legacy-id"]);
expect(page.meta.lastError).toBe("rebuilt after identity/schema mismatch");
closeRequestHistoryIndex();
db = new Database(dbFile);
try {
const revision = currentUsageLogRevision()!;
expect(metaValue(db, "source_dev")).toBe(revision.dev);
expect(metaValue(db, "source_ino")).toBe(revision.ino);
setMeta(db, "reuse_marker", "preserve");
} finally {
db.close();
}
expect((await queryRequestHistory({}, undefined, 10)).rows).toHaveLength(1);
closeRequestHistoryIndex();
db = new Database(dbFile);
try {
expect(metaValue(db, "reuse_marker")).toBe("preserve");
} finally {
db.close();
}
});

test("missing database and empty history produce an empty page", async () => {
const page = await queryRequestHistory({}, undefined, 10);
expect(page.rows).toEqual([]);
Expand Down
10 changes: 6 additions & 4 deletions tests/usage/request-history-rebuild-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* close or a replaced ledger never leaves a partial index or a stray build file.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readdirSync, renameSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { handleManagementAPI } from "../../src/server/management-api";
Expand Down Expand Up @@ -288,10 +288,12 @@ describe("request-history worker rebuild (#6990)", () => {
test("a build whose ledger is not the recorded file fails stale and leaves no file", () => {
writeLedger(ledgerLines(10));
const revision = currentUsageLogRevision()!;
// NTFS file ids exceed 2^53, where a Number cannot tell id and id + 1 apart (#7070 on
// Windows). The revision must carry the id exactly, and the neighbour must be rejected.
const exact = statSync(usageLogPath(), { bigint: true });
expect([revision.dev, revision.ino]).toEqual([exact.dev.toString(), exact.ino.toString()]);
const buildPath = buildFilePath(join(testDir, "unit.sqlite"), process.pid, 1);
// Change the volume, not the file id: an NTFS file id can exceed 2^53, where ino + 1 rounds
// back to the same number and the build sees the recorded file (windows CI, #7070).
expect(() => buildIndexFile(buildPath, "test", { ...revision, dev: revision.dev + 1 }))
expect(() => buildIndexFile(buildPath, "test", { ...revision, ino: (BigInt(revision.ino) + 1n).toString() }))
.toThrow(RequestHistoryIndexStaleError);
expect(readdirSync(testDir).filter(name => name.startsWith("unit.sqlite"))).toEqual([]);
});
Expand Down
Loading