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:
5
.changeset/fn-4027-startup-listtasks-memo.md
Normal file
5
.changeset/fn-4027-startup-listtasks-memo.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))) {
|
||||||
|
|||||||
Reference in New Issue
Block a user