perf(dashboard): kill startup SSE storm and slim hot-path task scans

- TaskStore.watch() now initializes lastPollTime so the first
  checkForChanges() poll filters by "modified since now" instead of
  doing an unfiltered SELECT * and emitting a task:updated event for
  every cached task. On a 1200-task board this dropped ~60 MB of SSE
  traffic and a 1199-call setState storm one second after dashboard
  startup.
- listTasks() gains a column option so callers can filter in SQL.
- dashboard CLI auto-merge sweeps (startup + 2 unpause handlers + the
  15s periodic retry) now use listTasks({ column: "in-review" })
  instead of pulling the full table on every cycle.
- self-healing archiveStaleDoneTasks() uses slim listTasks — it only
  needs id/column/columnMovedAt to decide staleness.
- Document the listTasks() perf contract and the watch() polling
  invariant in AGENTS.md and project memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-10 20:40:42 -07:00
parent 54fdeb66df
commit 1fe55b22f1
5 changed files with 112 additions and 7 deletions

View File

@@ -581,3 +581,52 @@ const isValid = timingSafeEqual(Buffer.from(signature), Buffer.from(req.headers[
- RoutineScheduler initialized after HeartbeatMonitor/TriggerScheduler - RoutineScheduler initialized after HeartbeatMonitor/TriggerScheduler
- Graceful degradation if RoutineStore not available (FN-1519 types incomplete) - Graceful degradation if RoutineStore not available (FN-1519 types incomplete)
- `getRoutineScheduler()` and `getRoutineRunner()` getters for testing access - `getRoutineScheduler()` and `getRoutineRunner()` getters for testing access
## Dashboard Startup Perf — `listTasks` Hot Paths
The dashboard CLI (`pnpm dev dashboard`) was extremely slow on boards with
~1200 tasks. Three independent code paths were each pulling the entire
`tasks` table (with the full `log`/`comments`/`steps` JSON, ~67 MB) at
startup and on every maintenance/sweep cycle.
**Bug 1 — `TaskStore.watch()` (`packages/core/src/store.ts`):** The 1-second
poll loop in `checkForChanges()` filters on `updatedAt > lastPollTime`, but
`lastPollTime` was left `null` after `watch()` populated the cache. The
first poll cycle therefore ran an unfiltered `SELECT *` and emitted a
`task:updated` SSE event for every cached task — ~60 MB of SSE traffic plus
1199 React `setState` calls one second after dashboard startup. **Fix:** set
`this.lastPollTime = new Date().toISOString()` at the end of `watch()` so
the first poll only sees tasks that changed *after* the cache snapshot.
**Bug 2 — Auto-merge sweeps (`packages/cli/src/commands/dashboard.ts`):**
The startup sweep, the two unpause handlers, and the periodic
`scheduleMergeRetry()` (every 15s by default) all called
`store.listTasks()` and then JS-filtered for in-review tasks. On a 1200-row
board with mostly done/archived tasks that's a constant 67 MB allocation
just to find 05 candidates. **Fix:** added `column?: Column` option to
`listTasks` so callers can scope the SQL `WHERE` directly, and changed
those four call sites to `listTasks({ column: "in-review" })`.
**Bug 3 — Engine maintenance (`packages/engine/src/self-healing.ts`):**
`SelfHealingManager.archiveStaleDoneTasks()` runs every 15 min from
`runMaintenance()`. It only needs `id`, `column`, and `columnMovedAt` to
decide which done tasks are >48h old, but it called the full
`listTasks()`. **Fix:** pass `{ slim: true }` — the slim row still includes
those fields and excludes the heavy log/comments/steps payload.
**General contract going forward:** `listTasks()` is heavy by default. Hot
paths must pass `{ slim: true }`, `{ column: ... }`, or
`{ includeArchived: false }`. The board endpoint
(`GET /api/tasks` in `packages/dashboard/src/routes.ts`) already uses
slim+includeArchived; the archived column is loaded lazily on expand via a
sticky `includeArchived` flag in `useTasks.ts`.
**Backlog cleanup:** `archiveStaleDoneTasks` walks tasks one at a time via
`store.archiveTask(id)`, which is fine for the steady-state 520 tasks per
cycle but would take minutes on the 866-task backlog after the
auto-archive feature first lands. For one-off backlog cleanup, a direct
SQL `UPDATE tasks SET column='archived', columnMovedAt=now,
updatedAt=now WHERE column='done' AND columnMovedAt < cutoff` is safe and
fast — subsequent watch() polls will pick up the changes and emit
`task:moved` events. (Beware emitting hundreds of events in one cycle if a
dashboard is connected.)

View File

@@ -120,6 +120,43 @@ Nested data (arrays, objects) is stored as JSON text in SQLite columns:
- Use `toJson()` for array columns, `toJsonNullable()` for nullable object columns - Use `toJson()` for array columns, `toJsonNullable()` for nullable object columns
- Use `fromJson<T>()` to parse JSON columns back to TypeScript types - Use `fromJson<T>()` to parse JSON columns back to TypeScript types
### `listTasks()` performance contract
`store.listTasks()` returns full Task rows by default, including `log`,
`comments`, `steps`, and `workflowStepResults`. On busy boards the `log`
column alone can exceed 60 MB, so a naive call from a hot path will stall
the dashboard. Always pass the narrowest option set the caller actually
needs:
- `{ slim: true }` — board-style listing; drops heavy fields and returns empty
arrays for them. Detail data must be fetched per-task via `getTask(id)`.
- `{ column: "in-review" }` — column-scoped scans (e.g. the auto-merge sweep).
Filtering happens in SQL, not in JS, so a 1200-row board collapses to
whatever the column actually holds.
- `{ includeArchived: false }` — exclude the archived column from the result
set. The board view uses this; archived tasks are loaded lazily when the
user expands that column.
The board path is wired this way already: `GET /api/tasks` uses
`{ slim: true, includeArchived: <query> }`, and `archiveStaleDoneTasks` /
the auto-merge sweeps in `dashboard.ts` use `slim`/`column`. New callers in
hot paths (engine maintenance, schedulers, SSE side-effects) MUST pick one
of these options — full `listTasks()` is reserved for tooling and tests.
### `TaskStore.watch()` polling
`watch()` populates an in-memory cache from `listTasks()` and starts a 1s
poll loop (`checkForChanges`) that emits `task:created`/`updated`/`moved`/
`deleted` events to SSE subscribers. The poll filters on `updatedAt /
columnMovedAt > lastPollTime` — and `lastPollTime` MUST be initialized to
"now" inside `watch()` itself. If it is left null, the first poll cycle
runs an unfiltered `SELECT *` and emits `task:updated` for every cached
task, causing tens of MB of SSE traffic and a frontend setState storm at
dashboard startup. Direct `UPDATE`s against `tasks` (e.g. bulk archive
sweeps) will also be observed by the next poll cycle and re-emitted as
events, which is fine in small numbers but should be batched if a sweep
touches hundreds of rows at once.
## Testing ## Testing
```bash ```bash

View File

@@ -876,7 +876,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// ── Startup sweep: enqueue any tasks already in "in-review" ─────── // ── Startup sweep: enqueue any tasks already in "in-review" ───────
if (settings.autoMerge) { if (settings.autoMerge) {
const existing = await store.listTasks(); const existing = await store.listTasks({ column: "in-review" });
const inReview = existing.filter((t) => canAutoMergeTask(t as any)); const inReview = existing.filter((t) => canAutoMergeTask(t as any));
if (inReview.length > 0) { if (inReview.length > 0) {
console.log( console.log(
@@ -911,7 +911,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (s.autoMerge) { if (s.autoMerge) {
try { try {
const tasks = await store.listTasks(); const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) { for (const t of tasks) {
if (canAutoMergeTask(t as any)) { if (canAutoMergeTask(t as any)) {
enqueueMerge(t.id); enqueueMerge(t.id);
@@ -935,7 +935,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (s.autoMerge) { if (s.autoMerge) {
try { try {
const tasks = await store.listTasks(); const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) { for (const t of tasks) {
if (canAutoMergeTask(t as any)) { if (canAutoMergeTask(t as any)) {
enqueueMerge(t.id); enqueueMerge(t.id);
@@ -999,7 +999,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Refresh the cached limit so the semaphore picks up live changes // Refresh the cached limit so the semaphore picks up live changes
cachedMaxConcurrent = s.maxConcurrent; cachedMaxConcurrent = s.maxConcurrent;
if (!s.globalPause && !s.enginePaused && s.autoMerge) { if (!s.globalPause && !s.enginePaused && s.autoMerge) {
const tasks = await store.listTasks(); const tasks = await store.listTasks({ column: "in-review" });
for (const t of tasks) { for (const t of tasks) {
if (canAutoMergeTask(t as any)) { if (canAutoMergeTask(t as any)) {
enqueueMerge(t.id); enqueueMerge(t.id);

View File

@@ -1316,9 +1316,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* from each row to make list responses cheap for board-style consumers. Detail fields default * from each row to make list responses cheap for board-style consumers. Detail fields default
* to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */ * to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */
slim?: boolean; slim?: boolean;
/** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */
column?: Column;
}): 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 slimColumns = ` const slimColumns = `
id, title, description, "column", status, size, reviewLevel, currentStep, id, title, description, "column", status, size, reviewLevel, currentStep,
@@ -1336,10 +1339,18 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
checkedOutBy, checkedOutAt checkedOutBy, checkedOutAt
`; `;
const selectClause = slim ? slimColumns : '*'; const selectClause = slim ? slimColumns : '*';
const whereClause = includeArchived ? '' : ` WHERE "column" != 'archived'`; const whereParts: string[] = [];
const params: string[] = [];
if (columnFilter) {
whereParts.push(`"column" = ?`);
params.push(columnFilter);
} else if (!includeArchived) {
whereParts.push(`"column" != 'archived'`);
}
const whereClause = whereParts.length > 0 ? ` WHERE ${whereParts.join(" AND ")}` : "";
const sql = `SELECT ${selectClause} FROM tasks${whereClause} ORDER BY createdAt ASC`; const sql = `SELECT ${selectClause} FROM tasks${whereClause} ORDER BY createdAt ASC`;
const rows = this.db.prepare(sql).all(); const rows = this.db.prepare(sql).all(...params);
const tasks = (rows as any[]).map((row) => this.rowToTask(row)); const tasks = (rows as any[]).map((row) => this.rowToTask(row));
// Sort by createdAt, then by numeric ID suffix for tie-breaking // Sort by createdAt, then by numeric ID suffix for tie-breaking
@@ -2699,6 +2710,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Store current lastModified // Store current lastModified
this.lastKnownModified = this.db.getLastModified(); this.lastKnownModified = this.db.getLastModified();
// Initialize lastPollTime so the first checkForChanges() cycle filters by
// "modified since now" instead of doing a full SELECT * + emitting an
// update event for every cached task. Without this, dashboard startup
// re-loaded the entire tasks table 1s after watch() began.
this.lastPollTime = new Date().toISOString();
// Use a sentinel watcher object so existing code that checks `this.watcher` still works // Use a sentinel watcher object so existing code that checks `this.watcher` still works
try { try {

View File

@@ -365,7 +365,10 @@ export class SelfHealingManager {
async archiveStaleDoneTasks(): Promise<number> { async archiveStaleDoneTasks(): Promise<number> {
try { try {
const tasks = await this.store.listTasks(); // Slim listing — we only need id/column/columnMovedAt/updatedAt to decide
// staleness. Pulling full task payloads (logs, comments, steps) here used
// to drag in tens of MB on busy boards and stalled the maintenance loop.
const tasks = await this.store.listTasks({ slim: true });
const cutoff = Date.now() - SelfHealingManager.AUTO_ARCHIVE_AFTER_MS; const cutoff = Date.now() - SelfHealingManager.AUTO_ARCHIVE_AFTER_MS;
const stale = tasks.filter((t) => { const stale = tasks.filter((t) => {