Files
pad/internal/store/dashboard_batch_test.go
T
xarmian 48104a5eff perf(server): collapse dashboard/bootstrap N+1 into set-based queries (BUG-2002) (#847)
The dashboard builder (also reused by the bootstrap endpoint and every
pad_set_workspace) ran ~1000 queries on a large workspace: one
GetItemLinks + per-link GetItem for every non-done item (blocked
attention + suggested_next filter), a GetChildItems per active plan
(progress + suggested_next), a GetItemIncludeDeleted per recent-activity
row, a GetCollection per visible collection, and a per-collection COUNT
via ListCollections whose result the dashboard never uses.

Replace every per-item/per-row loop with a set-based query:
- GetBlocksEdges: one workspace-wide JOIN of blocks-links -> blocker
  essentials, ordered created_at DESC to preserve the old first-active-
  blocker selection. Drives both blocked attention and the suggested_next
  blocked-filter (retires itemBlockedByActive).
- GetChildItemsForParents: one IN query grouping all active-plan children
  by parent (progress + suggested_next; no-content projection).
- GetItemsByIDsIncludeDeleted: one IN query batch-hydrating recent-activity
  items (include-deleted).
- ListItemsParams.NoContent: skip loading full markdown bodies on the
  count/summary scans (allItems, plans, stalled, orphaned).
- ListCollectionsMinimal now also selects slug; the dashboard uses it in
  place of ListCollections, dropping the unused per-collection COUNT N+1
  and the GetCollection-per-visible-id loop.

Per-item N+1s are gone; query count is now constant in workspace size.
Verified byte-identical dashboard + bootstrap JSON against three live
workspaces (docapp/claude/apm); dashboard latency ~376ms -> ~198ms on the
1907-item docapp workspace. New store methods are unit-tested.
2026-07-07 17:18:54 -04:00

260 lines
8.1 KiB
Go

package store
import (
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// linkChild wires child -> parent via a `parent` link (the shape
// GetChildItems / GetChildItemsForParents walk).
func linkChild(t *testing.T, s *Store, workspaceID, childID, parentID string) {
t.Helper()
if _, err := s.CreateItemLink(workspaceID, models.ItemLinkCreate{
TargetID: parentID,
LinkType: "parent",
}, childID); err != nil {
t.Fatalf("link child %s -> parent %s: %v", childID, parentID, err)
}
}
// linkBlocks wires blocker -> blocked via a `blocks` link.
func linkBlocks(t *testing.T, s *Store, workspaceID, blockerID, blockedID string) {
t.Helper()
if _, err := s.CreateItemLink(workspaceID, models.ItemLinkCreate{
TargetID: blockedID,
LinkType: "blocks",
}, blockerID); err != nil {
t.Fatalf("link blocker %s -> blocked %s: %v", blockerID, blockedID, err)
}
}
func TestGetChildItemsForParents(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
col := createTestCollection(t, s, ws.ID, "Tasks")
planA := createTestItem(t, s, ws.ID, col.ID, "Plan A", "")
planB := createTestItem(t, s, ws.ID, col.ID, "Plan B", "")
planC := createTestItem(t, s, ws.ID, col.ID, "Plan C (no children)", "")
a1 := createTestItem(t, s, ws.ID, col.ID, "A child 1", "body-a1")
a2 := createTestItem(t, s, ws.ID, col.ID, "A child 2", "body-a2")
b1 := createTestItem(t, s, ws.ID, col.ID, "B child 1", "body-b1")
linkChild(t, s, ws.ID, a1.ID, planA.ID)
linkChild(t, s, ws.ID, a2.ID, planA.ID)
linkChild(t, s, ws.ID, b1.ID, planB.ID)
byParent, err := s.GetChildItemsForParents([]string{planA.ID, planB.ID, planC.ID})
if err != nil {
t.Fatalf("GetChildItemsForParents: %v", err)
}
if got := len(byParent[planA.ID]); got != 2 {
t.Errorf("plan A: expected 2 children, got %d", got)
}
if got := len(byParent[planB.ID]); got != 1 {
t.Errorf("plan B: expected 1 child, got %d", got)
}
if _, ok := byParent[planC.ID]; ok {
t.Errorf("plan C: expected no map entry for childless parent")
}
// Grouping must match the per-parent GetChildItems (same rows).
single, err := s.GetChildItems(planA.ID)
if err != nil {
t.Fatalf("GetChildItems: %v", err)
}
if len(single) != len(byParent[planA.ID]) {
t.Errorf("batch child count %d != single %d for plan A", len(byParent[planA.ID]), len(single))
}
// The projection omits content (heavy body column) but keeps identity +
// computed ref/fields.
for _, child := range byParent[planA.ID] {
if child.Content != "" {
t.Errorf("expected empty content in batch projection, got %q for %s", child.Content, child.Title)
}
if child.Ref == "" {
t.Errorf("expected computed ref on child %s", child.Title)
}
}
}
func TestGetChildItemsForParentsExcludesDeleted(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
col := createTestCollection(t, s, ws.ID, "Tasks")
plan := createTestItem(t, s, ws.ID, col.ID, "Plan", "")
live := createTestItem(t, s, ws.ID, col.ID, "Live child", "")
gone := createTestItem(t, s, ws.ID, col.ID, "Deleted child", "")
linkChild(t, s, ws.ID, live.ID, plan.ID)
linkChild(t, s, ws.ID, gone.ID, plan.ID)
if err := s.DeleteItem(gone.ID); err != nil {
t.Fatalf("DeleteItem: %v", err)
}
byParent, err := s.GetChildItemsForParents([]string{plan.ID})
if err != nil {
t.Fatalf("GetChildItemsForParents: %v", err)
}
if got := len(byParent[plan.ID]); got != 1 {
t.Fatalf("expected 1 live child, got %d", got)
}
if byParent[plan.ID][0].ID != live.ID {
t.Errorf("expected live child, got %s", byParent[plan.ID][0].Title)
}
}
func TestGetChildItemsForParentsEmpty(t *testing.T) {
s := testStore(t)
byParent, err := s.GetChildItemsForParents(nil)
if err != nil {
t.Fatalf("GetChildItemsForParents(nil): %v", err)
}
if len(byParent) != 0 {
t.Errorf("expected empty map, got %d entries", len(byParent))
}
}
func TestGetBlocksEdges(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
col := createTestCollection(t, s, ws.ID, "Tasks")
blocker := createTestItem(t, s, ws.ID, col.ID, "Blocker", "")
blocked := createTestItem(t, s, ws.ID, col.ID, "Blocked", "")
// A non-blocks link must NOT appear in the result.
parent := createTestItem(t, s, ws.ID, col.ID, "Parent", "")
child := createTestItem(t, s, ws.ID, col.ID, "Child", "")
linkBlocks(t, s, ws.ID, blocker.ID, blocked.ID)
linkChild(t, s, ws.ID, child.ID, parent.ID)
edges, err := s.GetBlocksEdges(ws.ID)
if err != nil {
t.Fatalf("GetBlocksEdges: %v", err)
}
if len(edges) != 1 {
t.Fatalf("expected 1 blocks edge, got %d: %+v", len(edges), edges)
}
e := edges[0]
if e.TargetID != blocked.ID {
t.Errorf("expected target %s (blocked), got %s", blocked.ID, e.TargetID)
}
if e.SourceID != blocker.ID {
t.Errorf("expected source %s (blocker), got %s", blocker.ID, e.SourceID)
}
if e.SourceTitle != "Blocker" {
t.Errorf("expected source title 'Blocker', got %q", e.SourceTitle)
}
if e.SourceCollectionID != col.ID {
t.Errorf("expected source collection %s, got %s", col.ID, e.SourceCollectionID)
}
}
func TestGetBlocksEdgesExcludesDeleted(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
col := createTestCollection(t, s, ws.ID, "Tasks")
blocker := createTestItem(t, s, ws.ID, col.ID, "Blocker", "")
blocked := createTestItem(t, s, ws.ID, col.ID, "Blocked", "")
linkBlocks(t, s, ws.ID, blocker.ID, blocked.ID)
// Soft-deleting either endpoint drops the edge (join requires both live).
if err := s.DeleteItem(blocker.ID); err != nil {
t.Fatalf("DeleteItem: %v", err)
}
edges, err := s.GetBlocksEdges(ws.ID)
if err != nil {
t.Fatalf("GetBlocksEdges: %v", err)
}
if len(edges) != 0 {
t.Errorf("expected 0 edges after deleting blocker, got %d", len(edges))
}
}
func TestGetItemsByIDsIncludeDeleted(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
col := createTestCollection(t, s, ws.ID, "Tasks")
live := createTestItem(t, s, ws.ID, col.ID, "Live", "body")
gone := createTestItem(t, s, ws.ID, col.ID, "Archived", "body")
if err := s.DeleteItem(gone.ID); err != nil {
t.Fatalf("DeleteItem: %v", err)
}
got, err := s.GetItemsByIDsIncludeDeleted([]string{live.ID, gone.ID, "missing-id"})
if err != nil {
t.Fatalf("GetItemsByIDsIncludeDeleted: %v", err)
}
// Both live and soft-deleted must be present; missing ID absent.
if got[live.ID] == nil {
t.Errorf("expected live item present")
}
if got[gone.ID] == nil {
t.Errorf("expected soft-deleted item present (include-deleted)")
}
if _, ok := got["missing-id"]; ok {
t.Errorf("expected missing id absent")
}
if got[live.ID] != nil {
if got[live.ID].Title != "Live" {
t.Errorf("expected title 'Live', got %q", got[live.ID].Title)
}
if got[live.ID].Ref == "" {
t.Errorf("expected computed ref on hydrated item")
}
if got[live.ID].Content != "" {
t.Errorf("expected empty content in projection, got %q", got[live.ID].Content)
}
}
}
func TestGetItemsByIDsIncludeDeletedEmpty(t *testing.T) {
s := testStore(t)
got, err := s.GetItemsByIDsIncludeDeleted(nil)
if err != nil {
t.Fatalf("GetItemsByIDsIncludeDeleted(nil): %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty map, got %d", len(got))
}
}
func TestListItemsNoContentProjection(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
col := createTestCollection(t, s, ws.ID, "Tasks")
createTestItem(t, s, ws.ID, col.ID, "Has body", "the full markdown body")
// Default: content loaded.
full, err := s.ListItems(ws.ID, models.ItemListParams{})
if err != nil {
t.Fatalf("ListItems: %v", err)
}
if len(full) != 1 || full[0].Content != "the full markdown body" {
t.Fatalf("expected content loaded by default, got %+v", full)
}
// NoContent: body omitted, everything else intact.
skinny, err := s.ListItems(ws.ID, models.ItemListParams{NoContent: true})
if err != nil {
t.Fatalf("ListItems NoContent: %v", err)
}
if len(skinny) != 1 {
t.Fatalf("expected 1 item, got %d", len(skinny))
}
if skinny[0].Content != "" {
t.Errorf("expected empty content with NoContent, got %q", skinny[0].Content)
}
if skinny[0].Title != "Has body" {
t.Errorf("expected title preserved, got %q", skinny[0].Title)
}
}