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:
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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", [
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user