feat(FN-4027): memoize startup slim list reads to avoid redundant fresh fet

Added memoization for startup slim task list reads (`store.ts`), with dashboard server routing startup reads through the memo window and tests covering the store watcher consumers in scheduler and worktree-pool, plus performance documentation.

Fusion-Task-Id: FN-4027
This commit is contained in:
Fusion
2026-05-11 18:36:53 -07:00
committed by gsxdsm
parent 3a45a3fc25
commit 1772572191
9 changed files with 112 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Memoize startup slim `listTasks` reads across dashboard/engine boot paths to reduce duplicate task-list SQL and JSON parsing work without introducing long-lived stale cache behavior.

View File

@@ -206,6 +206,15 @@ const { stats: diffStats } = useTaskDiffStats(
## Cache Implementation Details ## Cache Implementation Details
## Startup slim `listTasks` memoization (FN-4027)
A short-lived startup memo now collapses duplicate `listTasks({ slim: true })` reads that happen during boot choreography (watch cache warmup, scheduler PR hydration, worktree pool scan, and dashboard badge priming).
- Scope: startup-only slim reads keyed by `includeArchived` + `column`
- Safety: memo entries expire quickly (2.5s TTL) and are explicitly cleared when `watch()` handoff completes
- Freshness: steady-state polling/watch reads bypass the memo, so normal runtime updates are not served from stale startup data
- Mutation safety: memoized responses are cloned before return so callers cannot poison shared cached objects
### useTaskDiffStats Cache ### useTaskDiffStats Cache
```typescript ```typescript

View File

@@ -8,6 +8,46 @@ describe("TaskStore", () => {
afterEach(harness.afterEach); afterEach(harness.afterEach);
describe("watcher and polling", () => { describe("watcher and polling", () => {
it("memoizes repeated startup slim list reads for matching options", async () => {
await harness.createTestTask();
const storeAny = harness.store() as any;
const prepareSpy = vi.spyOn(storeAny.db, "prepare");
await harness.store().listTasks({ slim: true, includeArchived: false, startupMemo: true });
await harness.store().listTasks({ slim: true, includeArchived: false, startupMemo: true });
const taskSelectCalls = prepareSpy.mock.calls.filter(([sql]) =>
typeof sql === "string" && sql.includes("FROM tasks") && sql.includes("ORDER BY createdAt ASC"),
);
expect(taskSelectCalls).toHaveLength(1);
});
it("separates startup memo entries by list options", async () => {
await harness.createTestTask();
const storeAny = harness.store() as any;
const prepareSpy = vi.spyOn(storeAny.db, "prepare");
await harness.store().listTasks({ slim: true, includeArchived: false, startupMemo: true });
await harness.store().listTasks({ slim: true, includeArchived: true, startupMemo: true });
const taskSelectCalls = prepareSpy.mock.calls.filter(([sql]) =>
typeof sql === "string" && sql.includes("FROM tasks") && sql.includes("ORDER BY createdAt ASC"),
);
expect(taskSelectCalls.length).toBeGreaterThanOrEqual(2);
});
it("invalidates startup memo once watch handoff is active", async () => {
await harness.createTestTask();
const storeAny = harness.store() as any;
await harness.store().listTasks({ slim: true, includeArchived: false, startupMemo: true });
expect(storeAny.startupSlimListMemo.size).toBeGreaterThan(0);
await harness.store().watch();
expect(storeAny.startupSlimListMemo.size).toBe(0);
harness.store().stopWatching();
});
it("cache is updated when polling is active even without fs.watch", async () => { it("cache is updated when polling is active even without fs.watch", async () => {
await harness.store().watch(); await harness.store().watch();

View File

@@ -599,6 +599,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private lastKnownModified: number = 0; private lastKnownModified: number = 0;
/** ISO timestamp of last poll — used to filter changed tasks */ /** ISO timestamp of last poll — used to filter changed tasks */
private lastPollTime: string | null = null; private lastPollTime: string | null = null;
/** Short-lived startup memo for repeated slim listTasks reads before steady-state watch/polling. */
private startupSlimListMemo = new Map<string, { expiresAt: number; promise: Promise<Task[]> }>();
private static readonly STARTUP_SLIM_LIST_MEMO_TTL_MS = 2_500;
/** Whether the store is actively watching for changes (watcher or polling). */ /** Whether the store is actively watching for changes (watcher or polling). */
private get isWatching(): boolean { private get isWatching(): boolean {
@@ -2784,10 +2787,36 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
slim?: boolean; slim?: boolean;
/** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */ /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */
column?: Column; column?: Column;
/** Opt-in startup-only memo for repeated slim reads during boot choreography. */
startupMemo?: boolean;
}): Promise<Task[]> { }): Promise<Task[]> {
const includeArchived = options?.includeArchived ?? true; const includeArchived = options?.includeArchived ?? true;
const slim = options?.slim ?? false; const slim = options?.slim ?? false;
const columnFilter = options?.column; const columnFilter = options?.column;
const startupMemoEnabled = options?.startupMemo ?? (!this.isWatching && slim);
if (startupMemoEnabled && slim && options?.limit === undefined && options?.offset === undefined) {
const memoKey = `${includeArchived ? "all" : "active"}:${columnFilter ?? "*"}`;
const now = Date.now();
const cached = this.startupSlimListMemo.get(memoKey);
if (cached && cached.expiresAt > now) {
const memoTasks = await cached.promise;
return JSON.parse(JSON.stringify(memoTasks)) as Task[];
}
const fetchPromise = this.listTasks({ ...options, startupMemo: false });
this.startupSlimListMemo.set(memoKey, {
expiresAt: now + TaskStore.STARTUP_SLIM_LIST_MEMO_TTL_MS,
promise: fetchPromise,
});
try {
const memoTasks = await fetchPromise;
return JSON.parse(JSON.stringify(memoTasks)) as Task[];
} catch (error) {
this.startupSlimListMemo.delete(memoKey);
throw error;
}
}
// Slim mode drops ONLY the agent log column. On busy boards `log` accounts // Slim mode drops ONLY the agent log column. On busy boards `log` accounts
// for ~99% of the row payload (60+ MB across 1200 tasks); every other JSON // for ~99% of the row payload (60+ MB across 1200 tasks); every other JSON
@@ -2855,6 +2884,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return sorted.slice(offset, offset + Math.max(0, limit)); return sorted.slice(offset, offset + Math.max(0, limit));
} }
private clearStartupSlimListMemo(): void {
this.startupSlimListMemo.clear();
}
/** /**
* List slim task rows with `updatedAt` strictly greater than the cursor. * List slim task rows with `updatedAt` strictly greater than the cursor.
* *
@@ -4932,11 +4965,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
*/ */
async watch(): Promise<void> { async watch(): Promise<void> {
if (this.watcher || this.pollInterval) return; // already watching if (this.watcher || this.pollInterval) return; // already watching
this.clearStartupSlimListMemo();
// Populate cache with current state. The watcher only needs metadata to // Populate cache with current state. The watcher only needs metadata to
// detect created/updated/moved/deleted events; full task logs stay on the // detect created/updated/moved/deleted events; full task logs stay on the
// detail path. // detail path.
const tasks = await this.listTasks({ slim: true }); const tasks = await this.listTasks({ slim: true, startupMemo: true });
this.taskCache.clear(); this.taskCache.clear();
for (const task of tasks) { for (const task of tasks) {
this.taskCache.set(task.id, { ...task }); this.taskCache.set(task.id, { ...task });
@@ -4975,6 +5009,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.pollInterval = setInterval(() => { this.pollInterval = setInterval(() => {
void this.checkForChanges(); void this.checkForChanges();
}, 1000); }, 1000);
this.clearStartupSlimListMemo();
} }
/** /**
@@ -5097,6 +5132,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.debounceTimers.clear(); this.debounceTimers.clear();
this.taskCache.clear(); this.taskCache.clear();
this.recentlyWritten.clear(); this.recentlyWritten.clear();
this.clearStartupSlimListMemo();
} }
/** /**

View File

@@ -1581,7 +1581,7 @@ export function setupBadgeWebSocket(
}; };
// Prime cache with existing tasks from default store // Prime cache with existing tasks from default store
void store.listTasks({ slim: true, includeArchived: false }).then((tasks) => { void store.listTasks({ slim: true, includeArchived: false, startupMemo: true }).then((tasks) => {
for (const task of tasks) { for (const task of tasks) {
badgeSnapshots.set(`default:${task.id}`, { badgeSnapshots.set(`default:${task.id}`, {
prInfo: task.prInfo ?? null, prInfo: task.prInfo ?? null,

View File

@@ -1698,6 +1698,23 @@ describe("Scheduler", () => {
}); });
describe("pr monitoring", () => { describe("pr monitoring", () => {
it("hydrates PR monitoring with startup memoized slim reads", async () => {
const prMonitor = {
startMonitoring: vi.fn(),
stopMonitoring: vi.fn(),
updatePrInfo: vi.fn(),
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
stopAll: vi.fn(),
} as unknown as PrMonitor;
const store = createMockStore();
const scheduler = new Scheduler(store, {});
scheduler.configurePrMonitoring({ prMonitor });
await flushAsyncWork();
expect(store.listTasks).toHaveBeenCalledWith({ slim: true, includeArchived: false, startupMemo: true });
});
it("stops monitoring when task moves out of in-review based on from column", () => { it("stops monitoring when task moves out of in-review based on from column", () => {
const prMonitor = { const prMonitor = {
startMonitoring: vi.fn(), startMonitoring: vi.fn(),

View File

@@ -576,6 +576,7 @@ describe("scanIdleWorktrees", () => {
const idle = await scanIdleWorktrees("/root", store); const idle = await scanIdleWorktrees("/root", store);
expect(store.listTasks).toHaveBeenCalledWith({ slim: true, includeArchived: false, startupMemo: true });
expect(idle).toContain("/root/.worktrees/calm-river"); expect(idle).toContain("/root/.worktrees/calm-river");
expect(idle).toContain("/root/.worktrees/bold-eagle"); expect(idle).toContain("/root/.worktrees/bold-eagle");
expect(idle).not.toContain("/root/.worktrees/swift-falcon"); expect(idle).not.toContain("/root/.worktrees/swift-falcon");

View File

@@ -516,7 +516,7 @@ export class Scheduler {
return; return;
} }
void this.store.listTasks({ slim: true, includeArchived: false }) void this.store.listTasks({ slim: true, includeArchived: false, startupMemo: true })
.then((tasks) => { .then((tasks) => {
const repo = getCurrentRepo(this.store.getRootDir()); const repo = getCurrentRepo(this.store.getRootDir());
if (!repo) return; if (!repo) return;

View File

@@ -304,7 +304,7 @@ export async function scanIdleWorktrees(rootDir: string, store: TaskStore): Prom
const registeredDirs = dirs.filter((dir) => registeredWorktrees.has(resolve(dir))); const registeredDirs = dirs.filter((dir) => registeredWorktrees.has(resolve(dir)));
// Find worktree paths assigned to non-done tasks (active worktrees) // Find worktree paths assigned to non-done tasks (active worktrees)
const tasks = await store.listTasks({ slim: true, includeArchived: false }); const tasks = await store.listTasks({ slim: true, includeArchived: false, startupMemo: true });
const activeWorktrees = new Set<string>(); const activeWorktrees = new Set<string>();
for (const task of tasks) { for (const task of tasks) {
if (task.worktree && task.column !== "done" && registeredWorktrees.has(resolve(task.worktree))) { if (task.worktree && task.column !== "done" && registeredWorktrees.has(resolve(task.worktree))) {