Files
fusion/docs/performance/dashboard-load.md
gsxdsm a242f1b449 fix(FN-7952): migrate bundled plugins to PostgreSQL (#2111)
## Summary

Bundled plugins now persist shared runtime state in project-scoped
PostgreSQL tables instead of maintaining independent SQLite authority.
Reports, CLI Printing Press, Compound Engineering, Roadmap, Even
Realities, and WhatsApp all follow the same ownership and startup
contract as Fusion core.

## Design decisions

- Plugin schema hooks run through the host’s PostgreSQL owner and
enforce project isolation.
- The SDK exposes the host contract needed by bundled plugins without
importing engine internals.
- Legacy Roadmap ownership fixtures use the supported empty-owner
sentinel, preserving current composite primary/foreign keys while
exercising backfill behavior.
- The lockfile travels with the Even Realities PostgreSQL dependency so
packaged installs remain reproducible.

## Validation

- All six affected plugin builds pass.
- Affected plugin suites pass: 773 tests across Printing Press, Compound
Engineering, Even Realities, Reports, Roadmap, and WhatsApp.
- `pnpm test:gate` passes all 478 gate tests.
- This PR changes 40 files.

## Stack

- Depends on #2110 → #2109 → #2108.
- The documentation/release PR completes the stack.

Related: #2105


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Breaking Changes**
* PostgreSQL is now required for runtime storage; SQLite files are used
only as one-time migration inputs.
  * The legacy `FUSION_NO_EMBEDDED_PG` fallback has been removed.

* **New Features**
* Added project-isolated PostgreSQL storage for plugins, reports, tasks,
notifications, and other plugin data.
  * Added agent tools for reports and CLI service drafts.
  * Added PostgreSQL schema initialization support for plugin authors.

* **Bug Fixes**
  * Improved migration and recovery of legacy plugin state.
* Prevented cross-project data access and strengthened transactional
schema updates.

* **Documentation**
* Updated storage, migration, deployment, plugin authoring, CLI, and
dashboard guidance for PostgreSQL.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-15 00:27:59 -07:00

10 KiB

Dashboard Load Performance Analysis

Historical pre-cutover analysis: the SQLite query plans below do not describe the current PostgreSQL runtime. Retained for performance archaeology and regression context.

Date: 2026-04-10 Task: FN-1532

Executive Summary

Profile analysis identified several SQLite query performance issues in the dashboard boot path. These issues cause unnecessary full table scans and temp B-tree sorts that slow dashboard load times, especially as project data grows.

Methodology

  1. Created a test database with representative data:

    • 100 tasks
    • 50 AI sessions
    • 200 activity log entries
    • 100 run audit events
  2. Analyzed query plans using EXPLAIN QUERY PLAN for boot-critical read operations

Query Plan Analysis Results

Issue 1: Tasks List with ORDER BY (CRITICAL)

Query: SELECT * FROM tasks ORDER BY createdAt ASC

Before:

SCAN tasks
USE TEMP B-TREE FOR ORDER BY

Problem: Full table scan with in-memory sorting. As the tasks table grows, this becomes increasingly expensive.

Solution: Add idxTasksCreatedAt on tasks(createdAt) - allows index scan with ordered retrieval.


Issue 2: AI Sessions Active List (HIGH)

Query: SELECT ... FROM ai_sessions WHERE status IN ('generating', 'awaiting_input', 'error') ORDER BY updatedAt DESC

Before:

SEARCH ai_sessions USING INDEX idxAiSessionsStatus (status=?)
USE TEMP B-TREE FOR ORDER BY

Problem: The existing idxAiSessionsStatus only indexes on status, but the query also needs updatedAt for ordering. This requires a temp B-tree to sort results.

Solution: Add idxAiSessionsStatusUpdatedAt on ai_sessions(status, updatedAt DESC) - covers both filtering and sorting.


Issue 3: Activity Log by Task ID (MEDIUM)

Query: SELECT * FROM activityLog WHERE taskId = ? ORDER BY timestamp DESC

Before:

SEARCH activityLog USING INDEX idxActivityLogTaskId (taskId=?)
USE TEMP B-TREE FOR ORDER BY

Problem: The existing idxActivityLogTaskId only indexes taskId, but the query also needs timestamp for ordering.

Solution: Add idxActivityLogTaskIdTimestamp on activityLog(taskId, timestamp DESC) - covers both filtering and ordering.


Issue 4: Activity Log by Type (MEDIUM)

Query: SELECT * FROM activityLog WHERE type = ? ORDER BY timestamp DESC

Before:

SEARCH activityLog USING INDEX idxActivityLogType (type=?)
USE TEMP B-TREE FOR ORDER BY

Problem: Similar to task ID - the type index doesn't include timestamp for ordering.

Solution: Add idxActivityLogTypeTimestamp on activityLog(type, timestamp DESC) - covers both filtering and ordering.


Issue 5: Agent Heartbeats List (MEDIUM)

Query: SELECT * FROM agentHeartbeats WHERE agentId = ? ORDER BY timestamp DESC

Before:

SEARCH agentHeartbeats USING INDEX idxAgentHeartbeatsAgentId (agentId=?)
USE TEMP B-TREE FOR ORDER BY

Problem: The agent heartbeat index doesn't include timestamp for ordering.

Solution: Add idxAgentHeartbeatsAgentIdTimestamp on agentHeartbeats(agentId, timestamp DESC) - covers both filtering and ordering.


Issue 6: Agents by State (LOW)

Query: SELECT * FROM agents WHERE state = 'idle'

Before:

SCAN agents

Problem: Full table scan on agents table. This affects any dashboard views that filter agents by state.

Solution: Add idxAgentsState on agents(state) - allows index-based lookup for state filtering.


Proposed Index Changes

Index Name Table Columns Purpose
idxTasksCreatedAt tasks createdAt Avoid temp B-tree for ORDER BY createdAt
idxAiSessionsStatusUpdatedAt ai_sessions status, updatedAt DESC Cover status filter + updatedAt ordering
idxActivityLogTaskIdTimestamp activityLog taskId, timestamp DESC Cover taskId filter + timestamp ordering
idxActivityLogTypeTimestamp activityLog type, timestamp DESC Cover type filter + timestamp ordering
idxAgentHeartbeatsAgentIdTimestamp agentHeartbeats agentId, timestamp DESC Cover agentId filter + timestamp ordering
idxAgentsState agents state Avoid full scan for state filtering

Impact Assessment

  • Boot time improvement: The listTasks index is the highest impact - it eliminates a full table scan + sort on every dashboard load
  • AI sessions: Eliminates sort overhead on every session list refresh
  • Activity log: Eliminates sort overhead on task detail views
  • Agents: Eliminates full scan on agent list filtering

Files Modified

  • packages/core/src/db.ts - Add migration for new indexes
  • packages/core/src/db.test.ts - Update expected index list
  • packages/core/src/run-audit.test.ts - Update expected index list
  • packages/core/src/__tests__/task-documents.test.ts - Update expected index list

Dashboard Card Rendering Performance (FN-1544)

Date: 2026-04-10 Task: FN-1544

Executive Summary

Performance analysis identified that TaskCard components triggered expensive network requests (session-files and diff endpoints) eagerly on initial render, causing sluggish board/list views with large task sets. Additionally, the memo comparator used high-cost JSON.stringify on attachments and comments arrays.

Issues Identified

Issue 1: Eager Session Files Fetching (HIGH)

Problem: Every TaskCard unconditionally called useSessionFiles hook, which fetches from /api/tasks/:id/session-files. With many cards visible or nearly-visible, this created a flood of network requests on initial render.

Solution: Added an enabled parameter to useSessionFiles (default true for backward compatibility). The hook returns stable empty state when disabled without triggering fetches.

Issue 2: Eager Diff Stats Fetching (HIGH)

Problem: Every TaskCard for done tasks unconditionally called useTaskDiffStats hook, which fetches from /api/tasks/:id/diff. This caused repeated fetches during rerenders.

Solution:

  1. Added an enabled parameter to useTaskDiffStats (default true for backward compatibility)
  2. Added short-lived in-memory caching (30-second TTL) keyed by taskId:projectId to avoid repeated fetches during rerenders
  3. Cache hits return immediately without loading flicker

Issue 3: High-Cost Memo Comparison (MEDIUM)

Problem: areTaskCardPropsEqual used JSON.stringify(attachments) and JSON.stringify(comments) for comparison. For tasks with many attachments or comments, this serialized entire arrays on every render cycle.

Solution: Replaced JSON.stringify with lightweight field-by-field comparison functions:

  • areAttachmentsEqual() - compares attachment counts and metadata fields (filename, mimeType, size)
  • areCommentsEqual() - compares comment counts and metadata fields (author, content, createdAt)

Mitigation Pattern: Viewport-Gated Card Metadata Loading

The key mitigation uses the existing isInViewport state with a 200px margin:

// TaskCard.tsx - Hook calls are gated on viewport visibility
const { files: sessionFiles, loading: sessionFilesLoading } = useSessionFiles(
  task.id,
  task.worktree,
  task.column,
  projectId,
  { enabled: isInViewport },  // Only fetch when card is visible
);

const { stats: diffStats } = useTaskDiffStats(
  task.id,
  task.column,
  task.mergeDetails?.commitSha,
  projectId,
  { enabled: isInViewport },  // Only fetch when card is visible
);

Benefits:

  • Offscreen cards don't trigger fetches
  • Cards entering viewport trigger fetches as expected
  • No new polling loops or background timers
  • Preserves existing card behaviors (badges, file counts, drag/drop, etc.)

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

const diffStatsCache = new Map<string, { stats: DiffStats; expiresAt: number }>();
const CACHE_TTL_MS = 30_000; // 30 seconds
  • Key format: "taskId:projectId"
  • Entries expire after TTL to ensure freshness
  • Cache is checked before initiating fetch - returns immediately on hit
  • Export __test_clearDiffStatsCache() for testing

Files Modified

  • packages/dashboard/app/hooks/useSessionFiles.ts - Added enabled option
  • packages/dashboard/app/hooks/useTaskDiffStats.ts - Added enabled option and caching
  • packages/dashboard/app/hooks/__tests__/useSessionFiles.test.ts - Tests for enabled option
  • packages/dashboard/app/hooks/__tests__/useTaskDiffStats.test.ts - Tests for enabled option and caching
  • packages/dashboard/app/components/TaskCard.tsx - Viewport-gated hook calls, lightweight memo comparison
  • packages/dashboard/app/components/TaskCard.test.tsx - Fixed pre-existing test expectations

Impact Assessment

  • Initial render: Significant reduction in network requests as offscreen cards don't fetch
  • Rerenders: Reduced CPU usage from eliminating JSON.stringify on large arrays
  • Cache efficiency: Repeated renders of the same task use cached diff stats instead of refetching
  • User experience: Board/list views feel more responsive, especially with many tasks

Key Learnings

  1. Viewport gating is effective - Using IntersectionObserver with a margin lets us fetch just-in-time without visible delay
  2. Caching with TTL prevents staleness - 30-second cache balances freshness with reduced network overhead
  3. Lightweight comparisons outperform serialization - Field-by-field comparison is O(n) vs JSON.stringify's O(n) + allocation overhead