Optimize bd list: replace N+1 label queries with bulk fetch

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-20 20:25:17 -05:00
parent 0916abf8fe
commit 968d9e2ea1
3 changed files with 30 additions and 10 deletions

View File

@@ -339,18 +339,19 @@ var listCmd = &cobra.Command{
} }
if jsonOutput { if jsonOutput {
// Populate labels for JSON output // Get labels and dependency counts in bulk (single query instead of N queries)
for _, issue := range issues {
issue.Labels, _ = store.GetLabels(ctx, issue.ID)
}
// Get dependency counts in bulk (single query instead of N queries)
issueIDs := make([]string, len(issues)) issueIDs := make([]string, len(issues))
for i, issue := range issues { for i, issue := range issues {
issueIDs[i] = issue.ID issueIDs[i] = issue.ID
} }
labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs)
depCounts, _ := store.GetDependencyCounts(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 // Build response with counts
issuesWithCounts := make([]*types.IssueWithCounts, len(issues)) issuesWithCounts := make([]*types.IssueWithCounts, len(issues))
for i, issue := range issues { for i, issue := range issues {
@@ -368,12 +369,18 @@ var listCmd = &cobra.Command{
return 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 { if longFormat {
// Long format: multi-line with details // Long format: multi-line with details
fmt.Printf("\nFound %d issues:\n\n", len(issues)) fmt.Printf("\nFound %d issues:\n\n", len(issues))
for _, issue := range issues { for _, issue := range issues {
// Load labels for display labels := labelsMap[issue.ID]
labels, _ := store.GetLabels(ctx, issue.ID)
fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status) fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status)
fmt.Printf(" %s\n", issue.Title) fmt.Printf(" %s\n", issue.Title)
@@ -388,8 +395,7 @@ var listCmd = &cobra.Command{
} else { } else {
// Compact format: one line per issue // Compact format: one line per issue
for _, issue := range issues { for _, issue := range issues {
// Load labels for display labels := labelsMap[issue.ID]
labels, _ := store.GetLabels(ctx, issue.ID)
labelsStr := "" labelsStr := ""
if len(labels) > 0 { if len(labels) > 0 {

View File

@@ -791,6 +791,19 @@ func (m *MemoryStorage) GetLabels(ctx context.Context, issueID string) ([]string
return m.labels[issueID], nil 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) { func (m *MemoryStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()

View File

@@ -35,6 +35,7 @@ type Storage interface {
AddLabel(ctx context.Context, issueID, label, actor string) error AddLabel(ctx context.Context, issueID, label, actor string) error
RemoveLabel(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) 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) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error)
// Ready Work & Blocking // Ready Work & Blocking