perf(server): mtime-gate the scheduler tick and memoize project-config reads (#200)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,14 @@
|
||||
* sent to AgentHub verbatim — string concatenation like `<provider>/<id>` is
|
||||
* forbidden everywhere in the pipeline. `default_model` / `vision_model` are `{
|
||||
* provider, model_id }` paired references (TOML tables).
|
||||
*
|
||||
* Reads are memoized on the file's mtime (see readTable): every consumer of this
|
||||
* service — scheduler model-ref validation, the models/schedules routes, usage
|
||||
* pricing — shares one parsed table per on-disk version instead of re-reading and
|
||||
* re-parsing the TOML per call. The service's own writes all funnel through
|
||||
* `writeRaw`, which invalidates synchronously; external edits (CLI, hand edits) are
|
||||
* caught by the stat. The cached table is shared between callers and must be treated
|
||||
* as immutable — every mutating method here copies before changing.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -24,12 +32,13 @@ import {
|
||||
GenerativeModel,
|
||||
catalogEntryFor,
|
||||
defaultProjectConfig,
|
||||
projectConfigFromTable,
|
||||
projectConfigPath,
|
||||
renderProjectConfigToml,
|
||||
resolveModelEnv,
|
||||
userText,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type { LLMOutcome, ModelRef, OmniMessage } from "@prismshadow/penguin-core";
|
||||
import type { LLMOutcome, ModelRef, OmniMessage, ProjectConfig } from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
ChatDefaultsDto,
|
||||
ModelInfo,
|
||||
@@ -41,6 +50,7 @@ import type {
|
||||
ModelTestResponse,
|
||||
} from "../api/types.js";
|
||||
import { badRequest } from "../http/validate.js";
|
||||
import { cacheable } from "../internal/mtime-gate.js";
|
||||
import type { PricingRates } from "./usage-service.js";
|
||||
|
||||
type RawTable = Record<string, unknown>;
|
||||
@@ -118,6 +128,15 @@ const SPEED_PROBE_PROMPT =
|
||||
"Count from 1 to 50 as a comma-separated list, and nothing else. Do not think or explain.\n<think></think>";
|
||||
|
||||
export class ProjectConfigService {
|
||||
/**
|
||||
* Parsed-table cache, one entry per Project, keyed by the config file's mtime as
|
||||
* recorded at read time (a fresh mtime is stored as a never-matching sentinel, see
|
||||
* mtime-gate). A repeat read while the stat still matches costs one stat and zero
|
||||
* parses; a mismatch (external edit) or a service write (writeRaw deletes the
|
||||
* entry) falls back to a full read.
|
||||
*/
|
||||
private readonly cache = new Map<string, { mtimeMs: number; table: RawTable }>();
|
||||
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
private filePath(projectId: string): string {
|
||||
@@ -140,14 +159,50 @@ export class ProjectConfigService {
|
||||
|
||||
/** Reads the raw TOML object; returns an empty object if the file doesn't exist (does not write to disk). */
|
||||
async readRaw(projectId: string): Promise<RawTable> {
|
||||
return (await this.readTable(projectId)) ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* mtime-gated read of the parsed table; null when the file doesn't exist (readRaw
|
||||
* flattens that to `{}`, loadConfig to the preset default config — the two
|
||||
* pre-existing missing-file behaviors). Serving the shared cached object is safe
|
||||
* because no consumer mutates it (see the class header).
|
||||
*/
|
||||
private async readTable(projectId: string): Promise<RawTable | null> {
|
||||
const file = this.filePath(projectId);
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = (await fs.stat(file)).mtimeMs;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
this.cache.delete(projectId); // Config deleted (or the whole Project): drop the stale entry.
|
||||
return null;
|
||||
}
|
||||
const cached = this.cache.get(projectId);
|
||||
if (cached && cached.mtimeMs === mtimeMs) return cached.table;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(this.filePath(projectId), "utf8");
|
||||
raw = await fs.readFile(file, "utf8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||||
throw err;
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
this.cache.delete(projectId); // Vanished between stat and read: same as never existing.
|
||||
return null;
|
||||
}
|
||||
return asTable(parseToml(raw));
|
||||
const table = asTable(parseToml(raw));
|
||||
this.cache.set(projectId, { mtimeMs: cacheable(mtimeMs), table });
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed view of the same cached table — byte-for-byte the semantics of core's
|
||||
* `loadProjectConfig` (missing file → preset default config; legacy format → the
|
||||
* same error, via core's shared narrowing) without its per-call readFile + parse.
|
||||
* Serves the scheduler's model-ref validation (see schedule-store).
|
||||
*/
|
||||
async loadConfig(projectId: string): Promise<ProjectConfig> {
|
||||
const table = await this.readTable(projectId);
|
||||
if (table === null) return defaultProjectConfig();
|
||||
return projectConfigFromTable(this.filePath(projectId), table);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +219,9 @@ export class ProjectConfigService {
|
||||
// (the same file should never have two formats).
|
||||
await fs.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 0o600 });
|
||||
await fs.chmod(file, 0o600);
|
||||
// Every service write funnels through here: invalidate synchronously so the next
|
||||
// read re-parses (external writers are caught by readTable's stat instead).
|
||||
this.cache.delete(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,7 @@ import { agentsDir, isSessionMeta, tracesDir } from "@prismshadow/penguin-core";
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core";
|
||||
import type { TraceFileRow, TraceSessionRow } from "../db/repos/trace-index.js";
|
||||
import { TraceIndexRepo } from "../db/repos/trace-index.js";
|
||||
import { cacheable, statMtime } from "../internal/mtime-gate.js";
|
||||
import { readTraceHead } from "../internal/trace-head.js";
|
||||
import { asSessionSource } from "../runtime/session-sources.js";
|
||||
import type { SessionSources } from "../runtime/session-sources.js";
|
||||
@@ -41,35 +42,12 @@ import { fallbackTitle } from "../runtime/title-generator.js";
|
||||
|
||||
const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
|
||||
|
||||
/**
|
||||
* mtimes younger than this are treated as UNSTABLE and never cached as clean:
|
||||
* filesystem timestamps are coarse (a write and a later change can land on the same
|
||||
* tick), so a fresh directory keeps being re-diffed until it has been quiet for this
|
||||
* long — an active Agent costs one small readdir per request while writing, and the
|
||||
* gate can never wedge on a same-tick change.
|
||||
*/
|
||||
const FRESH_MS = 2000;
|
||||
|
||||
/** A gate-cacheable mtime: the real value once stable, else a sentinel that never matches. */
|
||||
function cacheable(mtimeMs: number): number {
|
||||
return Date.now() - mtimeMs > FRESH_MS ? mtimeMs : -1;
|
||||
}
|
||||
|
||||
/** Absolute path of an indexed shard (reconstructed — rows never store paths; the data root may move). */
|
||||
export function traceFilePath(root: string, row: TraceFileRow): string {
|
||||
const name = `${row.sessionId}_${String(row.fileIndex).padStart(3, "0")}.jsonl`;
|
||||
return path.join(tracesDir(root, row.projectId, row.agentId), row.date, name);
|
||||
}
|
||||
|
||||
/** mtimeMs of a path; null when it does not exist. */
|
||||
async function statMtime(p: string): Promise<number | null> {
|
||||
try {
|
||||
return (await fs.stat(p)).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listDirs(dir: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
@@ -192,7 +170,7 @@ export class TraceIndexService {
|
||||
// a new file inside an EXISTING date dir — in practice always the newest one (the
|
||||
// Writer names dirs by current local date) — so gate on that single dir's mtime too.
|
||||
// Cached sentinels (-1: the dir was fresh when last seen) never match, forcing a
|
||||
// re-diff until the tree has been quiet (see FRESH_MS).
|
||||
// re-diff until the tree has been quiet (see mtime-gate's FRESH_MS).
|
||||
const newest = [...cached.dates.keys()].sort().at(-1);
|
||||
if (newest === undefined) return;
|
||||
this.counters.gateStats += 1;
|
||||
|
||||
Reference in New Issue
Block a user