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:
@@ -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 0–5 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 5–20 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.)
|
||||||
|
|||||||
37
AGENTS.md
37
AGENTS.md
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user