From 968d9e2ea107fd2e5bbf16d5c7efd80459e3aae6 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 20 Nov 2025 20:25:17 -0500 Subject: [PATCH] Optimize bd list: replace N+1 label queries with bulk fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: In direct mode, bd list was making a separate GetLabels() call for each issue when displaying labels. With 538 issues, this resulted in 538 separate database queries. While investigating the reported 5+ second slowness, discovered this N+1 query issue that would impact performance with many issues. Solution: 1. Added GetLabelsForIssues(issueIDs []string) to Storage interface 2. Implemented bulk fetch in SQLite (already existed, now exposed) 3. Implemented bulk fetch in MemoryStorage 4. Updated list.go to fetch all labels in single query Changes: - internal/storage/storage.go: Add GetLabelsForIssues to interface - internal/storage/memory/memory.go: Implement GetLabelsForIssues - cmd/bd/list.go: Use bulk fetching in all output modes Impact: Eliminates N queries for labels, replacing with 1 bulk query. This optimization applies to direct mode only (daemon mode already uses bulk operations via RPC). Note: The reported 5s slowness was actually caused by daemon auto-start timeout. Use --no-daemon flag or run 'bd migrate --update-repo-id' to resolve the legacy database issue causing daemon startup failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/bd/list.go | 26 ++++++++++++++++---------- internal/storage/memory/memory.go | 13 +++++++++++++ internal/storage/storage.go | 1 + 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/cmd/bd/list.go b/cmd/bd/list.go index 8436560d..62718549 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -339,18 +339,19 @@ var listCmd = &cobra.Command{ } if jsonOutput { - // Populate labels for JSON output - for _, issue := range issues { - issue.Labels, _ = store.GetLabels(ctx, issue.ID) - } - - // Get dependency counts in bulk (single query instead of N queries) + // Get labels and dependency counts in bulk (single query instead of N queries) issueIDs := make([]string, len(issues)) for i, issue := range issues { issueIDs[i] = issue.ID } + labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs) depCounts, _ := store.GetDependencyCounts(ctx, issueIDs) + // Populate labels for JSON output + for _, issue := range issues { + issue.Labels = labelsMap[issue.ID] + } + // Build response with counts issuesWithCounts := make([]*types.IssueWithCounts, len(issues)) for i, issue := range issues { @@ -368,12 +369,18 @@ var listCmd = &cobra.Command{ return } + // Load labels in bulk for display + issueIDs := make([]string, len(issues)) + for i, issue := range issues { + issueIDs[i] = issue.ID + } + labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs) + if longFormat { // Long format: multi-line with details fmt.Printf("\nFound %d issues:\n\n", len(issues)) for _, issue := range issues { - // Load labels for display - labels, _ := store.GetLabels(ctx, issue.ID) + labels := labelsMap[issue.ID] fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status) fmt.Printf(" %s\n", issue.Title) @@ -388,8 +395,7 @@ var listCmd = &cobra.Command{ } else { // Compact format: one line per issue for _, issue := range issues { - // Load labels for display - labels, _ := store.GetLabels(ctx, issue.ID) + labels := labelsMap[issue.ID] labelsStr := "" if len(labels) > 0 { diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 3d9432a5..7926c406 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -791,6 +791,19 @@ func (m *MemoryStorage) GetLabels(ctx context.Context, issueID string) ([]string return m.labels[issueID], nil } +func (m *MemoryStorage) GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make(map[string][]string) + for _, issueID := range issueIDs { + if labels, exists := m.labels[issueID]; exists { + result[issueID] = labels + } + } + return result, nil +} + func (m *MemoryStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 3f5d50fd..7974cc8f 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -35,6 +35,7 @@ type Storage interface { AddLabel(ctx context.Context, issueID, label, actor string) error RemoveLabel(ctx context.Context, issueID, label, actor string) error GetLabels(ctx context.Context, issueID string) ([]string, error) + GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) // Ready Work & Blocking