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:
Yaowei Zheng
2026-08-05 00:09:15 +08:00
committed by GitHub
parent c8894566e4
commit fc7cb5b8ed
11 changed files with 596 additions and 85 deletions
@@ -0,0 +1,106 @@
/**
* ProjectConfigService read-cache semantics: repeat reads are served from the
* mtime-keyed parsed-table cache (one initial readFile), the service's own writes
* invalidate synchronously, an external edit is caught by the stat, a fresh mtime is
* never cached as clean (same-second-granularity guard), and the typed loadConfig
* view mirrors core's loadProjectConfig (missing file → the preset default config).
*/
import fs from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
defaultProjectConfig,
projectConfigPath,
renderProjectConfigToml,
} from "@prismshadow/penguin-core";
import { ProjectConfigService } from "../src/services/project-config-service.js";
import { makeTempRoot } from "./helpers.js";
const P = "project-cfg";
const PRICED = {
provider: "custom",
model_id: "m1",
pricing: { unit: "usd_per_mtok", cache_read: 1, cache_write: 2, output: 3 },
};
/** Ages the config's mtime past the gate's FRESH_MS window (fixed instants keep backdates idempotent). */
async function backdate(file: string, instant: string): Promise<void> {
const at = new Date(instant);
await fs.utimes(file, at, at);
}
describe("project-config read cache", () => {
let root: string;
let svc: ProjectConfigService;
let file: string;
beforeEach(async () => {
root = await makeTempRoot();
svc = new ProjectConfigService(root);
file = projectConfigPath(root, P);
await svc.writeRaw(P, { name: "cached", models: [PRICED] });
await backdate(file, "2026-01-01T00:00:00Z");
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(root, { recursive: true, force: true });
});
it("repeat reads cost one readFile: pricing, name and the typed loadConfig view share the cached parse", async () => {
const reads = vi.spyOn(fs, "readFile");
expect(await svc.getPricing(P, "custom", "m1")).toEqual({
cacheRead: 1,
cacheWrite: 2,
output: 3,
});
expect(await svc.getPricing(P, "custom", "m1")).toEqual({
cacheRead: 1,
cacheWrite: 2,
output: 3,
});
expect(await svc.getName(P)).toBe("cached");
expect((await svc.loadConfig(P)).models.map((m) => m.model_id)).toEqual(["m1"]);
expect(reads).toHaveBeenCalledTimes(1);
});
it("a service write invalidates synchronously; once quiet, the next read re-parses exactly once", async () => {
await svc.getName(P); // Warm the cache at the stable mtime.
await svc.setName(P, "renamed");
await backdate(file, "2026-01-01T00:01:00Z");
const reads = vi.spyOn(fs, "readFile");
expect(await svc.getName(P)).toBe("renamed");
expect(await svc.getPricing(P, "custom", "m1")).toEqual({
cacheRead: 1,
cacheWrite: 2,
output: 3,
});
expect(reads).toHaveBeenCalledTimes(1);
});
it("an external edit (bypassing the service) is caught by the stat: new mtime → re-read", async () => {
expect(await svc.getName(P)).toBe("cached"); // Warm at the old mtime.
await fs.writeFile(
file,
renderProjectConfigToml({ name: "edited", models: [{ provider: "custom", model_id: "m2" }] }),
"utf8",
);
await backdate(file, "2026-01-01T00:02:00Z"); // Stable, but a different mtime than cached.
expect(await svc.getName(P)).toBe("edited");
expect((await svc.loadConfig(P)).models.map((m) => m.model_id)).toEqual(["m2"]);
expect(await svc.getPricing(P, "custom", "m1")).toBeUndefined();
});
it("a fresh mtime is never cached as clean: reads keep hitting disk until the file has been quiet", async () => {
await svc.setName(P, "fresh"); // mtime = now, inside the FRESH_MS window.
const reads = vi.spyOn(fs, "readFile");
await svc.getName(P);
await svc.getName(P);
expect(reads).toHaveBeenCalledTimes(2);
});
it("missing file: readRaw → {} and loadConfig → the preset default config (core loadProjectConfig parity)", async () => {
await svc.getName(P); // Warm, then delete out from under the cache.
await fs.rm(file);
expect(await svc.readRaw(P)).toEqual({});
expect(await svc.loadConfig(P)).toEqual(defaultProjectConfig());
});
});
+107 -2
View File
@@ -9,8 +9,13 @@
*/
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { saveProjectConfig, scheduleDir } from "@prismshadow/penguin-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
agentsDir,
projectConfigPath,
saveProjectConfig,
scheduleDir,
} from "@prismshadow/penguin-core";
import { openDatabase } from "../src/db/database.js";
import { ProjectsRepo } from "../src/db/repos/projects.js";
import { SchedulesRepo } from "../src/db/repos/schedules.js";
@@ -18,6 +23,7 @@ import { SessionsRepo } from "../src/db/repos/sessions.js";
import { UsersRepo } from "../src/db/repos/users.js";
import type { ErrorRecordArgs } from "../src/runtime/error-recorder.js";
import { Scheduler } from "../src/runtime/scheduler.js";
import { ProjectConfigService } from "../src/services/project-config-service.js";
import type { ScheduleServerEvent } from "../src/api/types.js";
import { makeTempRoot } from "./helpers.js";
@@ -95,6 +101,7 @@ describe("scheduler", () => {
return { sessionId };
},
},
projectConfig: new ProjectConfigService(root),
errors: { record: (args) => void errors.push(args) },
notify: (userId, event) => void events.push({ userId, event }),
now: () => nowMs,
@@ -132,6 +139,104 @@ describe("scheduler", () => {
return new Date(ms).toISOString();
}
/**
* Ages the given paths' mtimes past the mtime gate's FRESH_MS window (fresh mtimes are
* deliberately never cached as clean), so steady-state assertions see a tree that looks
* quiet — exactly like files written minutes ago. Fixed instants keep backdates idempotent
* and let a later edit be given a *different* stable mtime.
*/
async function backdate(paths: string[], instant = "2026-01-01T00:00:00Z"): Promise<void> {
const at = new Date(instant);
for (const p of paths) await fs.utimes(p, at, at);
}
it("steady state: an unchanged tree ticks with zero file reads and zero dir listings (stat-gated); a new file re-scans", async () => {
insertSession("session-1");
// Carries a model reference so every tick also exercises model-ref validation —
// the per-tick .project_config.toml read+parse this change eliminates.
await writeFile("cached", [
`prompt = "cached prompt"`,
`enabled = true`,
`start_at = "${iso(T0 + 60 * MIN)}"`,
`period = "30m"`,
`provider = "custom"`,
`model_id = "m-bench"`,
]);
await backdate([
agentsDir(root, P),
scheduleDir(root, P, A),
path.join(scheduleDir(root, P, A), "cached.toml"),
projectConfigPath(root, P),
]);
await scheduler.tickOnce(); // First look: populates the gate caches (this pass reads).
expect(repo.find(P, A, "cached")).not.toBeNull();
const fileReads = scheduler.files.counters.fileReads;
const dirScans = scheduler.files.counters.dirScans;
const readFileSpy = vi.spyOn(fs, "readFile");
const readdirSpy = vi.spyOn(fs, "readdir");
await scheduler.tickOnce();
await scheduler.tickOnce();
// Unchanged tree: the ticks are pure stats — no schedule-file reads, no readdir of the
// agents/schedule dirs, and no .project_config.toml read for model-ref validation either.
expect(scheduler.files.counters.fileReads).toBe(fileReads);
expect(scheduler.files.counters.dirScans).toBe(dirScans);
expect(readFileSpy).not.toHaveBeenCalled();
expect(readdirSpy).not.toHaveBeenCalled();
readFileSpy.mockRestore();
readdirSpy.mockRestore();
// A new file moves the schedule dir's mtime: the gate notices and registers it.
await writeFile("added", [
`prompt = "a"`,
`enabled = true`,
`start_at = "${iso(T0 + 60 * MIN)}"`,
`session_id = "session-1"`,
]);
await scheduler.tickOnce();
expect(repo.find(P, A, "added")).not.toBeNull();
expect(scheduler.files.counters.dirScans).toBe(dirScans + 1);
});
it("an in-place edit (file mtime moves, dir mtime does not) is picked up next tick and fires with the new content", async () => {
insertSession("session-1");
await writeFile("inplace", [
`prompt = "old prompt"`,
`enabled = true`,
`start_at = "${iso(T0 + 5 * MIN)}"`,
`period = "5m"`,
`session_id = "session-1"`,
]);
await backdate([
agentsDir(root, P),
scheduleDir(root, P, A),
path.join(scheduleDir(root, P, A), "inplace.toml"),
projectConfigPath(root, P),
]);
await scheduler.tickOnce(); // Populate the gate caches at the stable mtimes.
await scheduler.tickOnce(); // Steady state: served from memory.
// Overwriting an existing file's CONTENT never moves the directory's mtime — only the
// file's own — which is exactly what the per-file stats of the unchanged-dir path catch.
await writeFile("inplace", [
`prompt = "new prompt"`,
`enabled = true`,
`start_at = "${iso(T0 + 5 * MIN)}"`,
`period = "5m"`,
`session_id = "session-1"`,
]);
await backdate([path.join(scheduleDir(root, P, A), "inplace.toml")], "2026-01-01T00:01:00Z");
nowMs = T0 + 5 * MIN;
const dirScans = scheduler.files.counters.dirScans;
await scheduler.tickOnce();
expect(started).toHaveLength(1);
expect(started[0]?.text).toContain("new prompt");
expect(started[0]?.text).not.toContain("old prompt");
// The pickup came from the unchanged-dir path's per-file stat, not a directory rescan.
expect(scheduler.files.counters.dirScans).toBe(dirScans);
});
it("periodic task: registration consumes past slots (missed, not backfilled); fires once on time, never twice", async () => {
insertSession("session-1");
await writeFile("report", [
+31
View File
@@ -183,6 +183,37 @@ describe("schedules api", () => {
expect(stale?.creatorUserId).toBe("owner_a");
});
it("a hand-edited file is reconciled past a warm mtime cache (an in-place edit moves only the file's mtime)", async () => {
expect(
(
await owner.post(base, {
name: "hand",
prompt: "before",
enabled: true,
startAt: FUTURE,
period: "30m",
})
).status,
).toBe(201);
// Make the tree look quiet (a fresh mtime is never cached as clean), then warm the
// scan cache with one GET — the steady state the schedule routes now serve from.
const dir = scheduleDir(t.root, projectId, "default_agent");
const file = path.join(dir, "hand.toml");
const old = new Date("2026-01-01T00:00:00Z");
await fs.utimes(dir, old, old);
await fs.utimes(file, old, old);
let list = (await (await owner.get(base)).json()) as SchedulesResponse;
expect(list.schedules[0]?.prompt).toBe("before");
// Hand-edit the file in place (like over SSH): the content and file mtime change,
// the directory's mtime does NOT — the per-file stat still catches it.
const raw = await fs.readFile(file, "utf8");
await fs.writeFile(file, raw.replace('"before"', '"after"'), "utf8");
const edited = new Date("2026-01-01T00:01:00Z");
await fs.utimes(file, edited, edited);
list = (await (await owner.get(base)).json()) as SchedulesResponse;
expect(list.schedules[0]?.prompt).toBe("after");
});
it("a file holding model_id without provider lands in invalidFiles instead of being scheduled", async () => {
// What a schedule file persisted before the pairing rule looks like: the provider is
// never filled in for it, the file is simply reported invalid and skipped.