perf(goals): optimize gt goals from 6s to <50ms via direct SQLite (gt-aps.3)

Replace bd subprocess spawns with direct SQLite queries:
- queryEpicsInDir: direct sqlite3 query vs bd list subprocess
- getLinkedConvoys: direct JOIN query vs bd dep list + getIssueDetails loop
- computeGoalLastMovement: reuse epic.UpdatedAt vs separate bd show call

Also includes mailbox optimization from earlier session:
- Consolidated multiple parallel queries into single bd list --all query
- Filters in Go instead of spawning O(identities × statuses) bd processes

177x improvement (6.2s → 35ms) by eliminating subprocess overhead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
diesel
2026-01-24 17:18:43 -08:00
committed by John Ogle
parent 6f0282f1c6
commit 05b716f4a3
2 changed files with 163 additions and 135 deletions

View File

@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -119,11 +120,11 @@ func showGoal(goalID string) error {
return fmt.Errorf("'%s' is not a goal/epic (type: %s)", goalID, goal.IssueType) return fmt.Errorf("'%s' is not a goal/epic (type: %s)", goalID, goal.IssueType)
} }
// Get linked convoys // Get linked convoys (no dbPath available for single goal lookup, use fallback)
convoys := getLinkedConvoys(goalID) convoys := getLinkedConvoys(goalID, "")
// Compute staleness // Compute staleness
lastMovement := computeGoalLastMovement(goalID, convoys) lastMovement := computeGoalLastMovement(goal.UpdatedAt, convoys)
stalenessHrs := time.Since(lastMovement).Hours() stalenessHrs := time.Since(lastMovement).Hours()
icon := stalenessIcon(stalenessHrs) icon := stalenessIcon(stalenessHrs)
@@ -210,8 +211,8 @@ func listGoals() error {
// Build goal info with staleness computation // Build goal info with staleness computation
var goals []goalInfo var goals []goalInfo
for _, epic := range epics { for _, epic := range epics {
convoys := getLinkedConvoys(epic.ID) convoys := getLinkedConvoys(epic.ID, epic.dbPath)
lastMovement := computeGoalLastMovement(epic.ID, convoys) lastMovement := computeGoalLastMovement(epic.UpdatedAt, convoys)
stalenessHrs := time.Since(lastMovement).Hours() stalenessHrs := time.Since(lastMovement).Hours()
icon := stalenessIcon(stalenessHrs) icon := stalenessIcon(stalenessHrs)
@@ -291,11 +292,61 @@ type convoyInfo struct {
} }
// getLinkedConvoys finds convoys linked to a goal (via parent-child relation). // getLinkedConvoys finds convoys linked to a goal (via parent-child relation).
func getLinkedConvoys(goalID string) []convoyInfo { // dbPath is the path to beads.db containing the goal for direct SQLite queries.
func getLinkedConvoys(goalID, dbPath string) []convoyInfo {
var convoys []convoyInfo
// If no dbPath provided, fall back to bd subprocess (shouldn't happen normally)
if dbPath == "" {
return getLinkedConvoysFallback(goalID)
}
// Query dependencies directly from SQLite
// Children are stored as: depends_on_id = goalID (parent) with type 'blocks'
safeGoalID := strings.ReplaceAll(goalID, "'", "''")
query := fmt.Sprintf(`
SELECT i.id, i.title, i.status
FROM dependencies d
JOIN issues i ON d.issue_id = i.id
WHERE d.depends_on_id = '%s' AND d.type = 'blocks' AND i.issue_type = 'convoy'
`, safeGoalID)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return convoys
}
if stdout.Len() == 0 {
return convoys
}
var results []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
}
if err := json.Unmarshal(stdout.Bytes(), &results); err != nil {
return convoys
}
for _, r := range results {
convoys = append(convoys, convoyInfo{
ID: r.ID,
Title: r.Title,
Status: r.Status,
})
}
return convoys
}
// getLinkedConvoysFallback uses bd subprocess (for when dbPath is unknown).
func getLinkedConvoysFallback(goalID string) []convoyInfo {
var convoys []convoyInfo var convoys []convoyInfo
// Query dependencies where this goal is the parent
// The child issues (convoys) will have depends_on_id = goalID with type = 'parent-child'
depArgs := []string{"dep", "list", goalID, "--json"} depArgs := []string{"dep", "list", goalID, "--json"}
depCmd := exec.Command("bd", depArgs...) depCmd := exec.Command("bd", depArgs...)
var stdout bytes.Buffer var stdout bytes.Buffer
@@ -315,7 +366,6 @@ func getLinkedConvoys(goalID string) []convoyInfo {
return convoys return convoys
} }
// Get details for each child that is a convoy
for _, child := range deps.Children { for _, child := range deps.Children {
details := getIssueDetails(child.ID) details := getIssueDetails(child.ID)
if details != nil && details.IssueType == "convoy" { if details != nil && details.IssueType == "convoy" {
@@ -332,27 +382,22 @@ func getLinkedConvoys(goalID string) []convoyInfo {
// computeGoalLastMovement computes when the goal last had activity. // computeGoalLastMovement computes when the goal last had activity.
// It looks at: // It looks at:
// 1. The goal's own updated_at // 1. The goal's own updated_at (passed directly to avoid re-querying)
// 2. The last activity of any linked convoy's tracked issues // 2. The last activity of any linked convoy's tracked issues
func computeGoalLastMovement(goalID string, convoys []convoyInfo) time.Time { func computeGoalLastMovement(goalUpdatedAt string, convoys []convoyInfo) time.Time {
// Start with the goal's own updated_at // Start with the goal's own updated_at
showCmd := exec.Command("bd", "show", goalID, "--json")
var stdout bytes.Buffer
showCmd.Stdout = &stdout
showCmd.Run()
var goals []struct {
UpdatedAt string `json:"updated_at"`
}
json.Unmarshal(stdout.Bytes(), &goals)
lastMovement := time.Now().Add(-24 * time.Hour) // Default to 24 hours ago lastMovement := time.Now().Add(-24 * time.Hour) // Default to 24 hours ago
if len(goals) > 0 && goals[0].UpdatedAt != "" { if goalUpdatedAt != "" {
if t, err := time.Parse(time.RFC3339, goals[0].UpdatedAt); err == nil { if t, err := time.Parse(time.RFC3339, goalUpdatedAt); err == nil {
lastMovement = t lastMovement = t
} }
} }
// If no convoys, return early (common case - avoids unnecessary work)
if len(convoys) == 0 {
return lastMovement
}
// Check convoy activity // Check convoy activity
townBeads, err := getTownBeadsDir() townBeads, err := getTownBeadsDir()
if err != nil { if err != nil {
@@ -470,6 +515,8 @@ type epicRecord struct {
Priority int `json:"priority"` Priority int `json:"priority"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
Assignee string `json:"assignee"` Assignee string `json:"assignee"`
// dbPath is the path to beads.db containing this epic (for direct queries)
dbPath string
} }
// collectEpicsFromAllRigs queries all rigs for epics and aggregates them. // collectEpicsFromAllRigs queries all rigs for epics and aggregates them.
@@ -541,27 +588,53 @@ func collectEpicsFromAllRigs() ([]epicRecord, error) {
return allEpics, nil return allEpics, nil
} }
// queryEpicsInDir runs bd list --type=epic in the specified directory. // queryEpicsInDir queries epics directly from SQLite in the specified directory.
// If dir is empty, uses current working directory. // If dir is empty, uses current working directory.
func queryEpicsInDir(dir string) ([]epicRecord, error) { func queryEpicsInDir(dir string) ([]epicRecord, error) {
listArgs := []string{"list", "--type=epic", "--json"} beadsDir := dir
if goalsStatus != "" && goalsStatus != "open" { if beadsDir == "" {
if goalsStatus == "all" { var err error
listArgs = append(listArgs, "--all") beadsDir, err = os.Getwd()
} else { if err != nil {
listArgs = append(listArgs, "--status="+goalsStatus) return nil, fmt.Errorf("getting working directory: %w", err)
} }
} }
listCmd := exec.Command("bd", listArgs...) // Resolve redirects to find actual beads.db
if dir != "" { resolvedBeads := beads.ResolveBeadsDir(beadsDir)
listCmd.Dir = dir dbPath := filepath.Join(resolvedBeads, "beads.db")
}
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil { // Check if database exists
return nil, fmt.Errorf("listing epics: %w", err) if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, nil // No database, no epics
}
// Build SQL query for epics
query := `SELECT id, title, status, priority, updated_at, assignee
FROM issues
WHERE issue_type = 'epic'`
if goalsStatus == "" || goalsStatus == "open" {
query += ` AND status <> 'closed' AND status <> 'tombstone'`
} else if goalsStatus != "all" {
query += fmt.Sprintf(` AND status = '%s'`, strings.ReplaceAll(goalsStatus, "'", "''"))
} else {
// --all: exclude tombstones but include everything else
query += ` AND status <> 'tombstone'`
}
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
// Database might be empty or have no epics - not an error
return nil, nil
}
// Handle empty result (sqlite3 -json returns nothing for empty sets)
if stdout.Len() == 0 {
return nil, nil
} }
var epics []epicRecord var epics []epicRecord
@@ -569,5 +642,10 @@ func queryEpicsInDir(dir string) ([]epicRecord, error) {
return nil, fmt.Errorf("parsing epics: %w", err) return nil, fmt.Errorf("parsing epics: %w", err)
} }
// Set dbPath on each epic for direct queries later
for i := range epics {
epics[i].dbPath = dbPath
}
return epics, nil return epics, nil
} }

View File

@@ -9,7 +9,6 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"sync"
"time" "time"
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
@@ -108,87 +107,71 @@ func (m *Mailbox) listBeads() ([]*Message, error) {
return messages, nil return messages, nil
} }
// queryResult holds the result of a single query.
type queryResult struct {
messages []*Message
err error
}
// listFromDir queries messages from a beads directory. // listFromDir queries messages from a beads directory.
// Returns messages where identity is the assignee OR a CC recipient. // Returns messages where identity is the assignee OR a CC recipient.
// Includes both open and hooked messages (hooked = auto-assigned handoff mail). // Includes both open and hooked messages (hooked = auto-assigned handoff mail).
// If all queries fail, returns the last error encountered. // Uses a single consolidated query for performance (<100ms vs 10s+ for parallel queries).
// Queries are parallelized for performance (~6x speedup).
func (m *Mailbox) listFromDir(beadsDir string) ([]*Message, error) { func (m *Mailbox) listFromDir(beadsDir string) ([]*Message, error) {
// Get all identity variants to query (handles legacy vs normalized formats) // Get all identity variants to match (handles legacy vs normalized formats)
identities := m.identityVariants() identities := m.identityVariants()
// Build list of queries to run in parallel // Single query: get all messages of type=message (open and hooked, not closed)
type querySpec struct { // We use --all to include hooked status, then filter out closed in Go
filterFlag string args := []string{"list",
filterValue string "--type", "message",
status string "--all",
"--limit", "0",
"--json",
} }
var queries []querySpec
// Assignee queries for each identity variant in both open and hooked statuses stdout, err := runBdCommand(args, m.workDir, beadsDir)
for _, identity := range identities { if err != nil {
for _, status := range []string{"open", "hooked"} { return nil, fmt.Errorf("mailbox query failed: %w", err)
queries = append(queries, querySpec{ }
filterFlag: "--assignee",
filterValue: identity, // Parse JSON output
status: status, var beadsMsgs []BeadsMessage
}) if err := json.Unmarshal(stdout, &beadsMsgs); err != nil {
// Empty result
if len(stdout) == 0 || string(stdout) == "null" {
return nil, nil
} }
return nil, err
} }
// CC queries for each identity variant (open only) // Build identity lookup set for fast matching
for _, identity := range identities { identitySet := make(map[string]bool, len(identities))
queries = append(queries, querySpec{ for _, id := range identities {
filterFlag: "--label", identitySet[id] = true
filterValue: "cc:" + identity,
status: "open",
})
} }
// Execute all queries in parallel // Filter messages: (assignee match AND status in [open,hooked]) OR (cc match AND status=open)
results := make([]queryResult, len(queries))
var wg sync.WaitGroup
wg.Add(len(queries))
for i, q := range queries {
go func(idx int, spec querySpec) {
defer wg.Done()
msgs, err := m.queryMessages(beadsDir, spec.filterFlag, spec.filterValue, spec.status)
results[idx] = queryResult{messages: msgs, err: err}
}(i, q)
}
wg.Wait()
// Collect results
seen := make(map[string]bool)
var messages []*Message var messages []*Message
var lastErr error for _, bm := range beadsMsgs {
anySucceeded := false // Skip closed messages
if bm.Status == "closed" {
continue
}
for _, r := range results { // Check if assignee matches any identity variant
if r.err != nil { assigneeMatch := identitySet[bm.Assignee]
lastErr = r.err
} else { // Check if any CC label matches identity variants
anySucceeded = true ccMatch := false
for _, msg := range r.messages { bm.ParseLabels()
if !seen[msg.ID] { for _, cc := range bm.GetCC() {
seen[msg.ID] = true if identitySet[cc] {
messages = append(messages, msg) ccMatch = true
} break
} }
} }
}
// If ALL queries failed, return the last error // Include if: (assignee match AND open/hooked) OR (cc match AND open)
if !anySucceeded && lastErr != nil { if assigneeMatch && (bm.Status == "open" || bm.Status == "hooked") {
return nil, fmt.Errorf("all mailbox queries failed: %w", lastErr) messages = append(messages, bm.ToMessage())
} else if ccMatch && bm.Status == "open" {
messages = append(messages, bm.ToMessage())
}
} }
return messages, nil return messages, nil
@@ -210,39 +193,6 @@ func (m *Mailbox) identityVariants() []string {
return variants return variants
} }
// queryMessages runs a bd list query with the given filter flag and value.
func (m *Mailbox) queryMessages(beadsDir, filterFlag, filterValue, status string) ([]*Message, error) {
args := []string{"list",
"--type", "message",
filterFlag, filterValue,
"--status", status,
"--json",
}
stdout, err := runBdCommand(args, m.workDir, beadsDir)
if err != nil {
return nil, err
}
// Parse JSON output
var beadsMsgs []BeadsMessage
if err := json.Unmarshal(stdout, &beadsMsgs); err != nil {
// Empty inbox returns empty array or nothing
if len(stdout) == 0 || string(stdout) == "null" {
return nil, nil
}
return nil, err
}
// Convert to GGT messages - wisp status comes from beads issue.wisp field
var messages []*Message
for _, bm := range beadsMsgs {
messages = append(messages, bm.ToMessage())
}
return messages, nil
}
func (m *Mailbox) listLegacy() ([]*Message, error) { func (m *Mailbox) listLegacy() ([]*Message, error) {
file, err := os.Open(m.path) file, err := os.Open(m.path)
if err != nil { if err != nil {