Files
beads/internal/storage/sqlite/ready.go
Steve Yegge ed23f8f4fe Optimize GetReadyWork to use blocked_issues_cache
Replaces expensive recursive CTE query with simple cache lookup,
achieving 96% performance improvement on 10K databases (bd-5qim).

Performance results:
- Before: ~752ms (recursive CTE on every call)
- After: ~29ms (cache lookup + filters)
- Target: <50ms ✓

The query now uses a simple NOT EXISTS check against the
blocked_issues_cache table instead of computing the full
blocked issue tree on every call.

Cache is maintained by invalidateBlockedCache() called on
dependency and status changes (added in next commit).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 19:29:30 -05:00

309 lines
8.6 KiB
Go

package sqlite
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/steveyegge/beads/internal/types"
)
// GetReadyWork returns issues with no open blockers
// By default, shows both 'open' and 'in_progress' issues so epics/tasks
// ready to close are visible (bd-165)
func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
whereClauses := []string{}
args := []interface{}{}
// Default to open OR in_progress if not specified (bd-165)
if filter.Status == "" {
whereClauses = append(whereClauses, "i.status IN ('open', 'in_progress')")
} else {
whereClauses = append(whereClauses, "i.status = ?")
args = append(args, filter.Status)
}
if filter.Priority != nil {
whereClauses = append(whereClauses, "i.priority = ?")
args = append(args, *filter.Priority)
}
if filter.Assignee != nil {
whereClauses = append(whereClauses, "i.assignee = ?")
args = append(args, *filter.Assignee)
}
// Label filtering (AND semantics)
if len(filter.Labels) > 0 {
for _, label := range filter.Labels {
whereClauses = append(whereClauses, `
EXISTS (
SELECT 1 FROM labels
WHERE issue_id = i.id AND label = ?
)
`)
args = append(args, label)
}
}
// Label filtering (OR semantics)
if len(filter.LabelsAny) > 0 {
placeholders := make([]string, len(filter.LabelsAny))
for i := range filter.LabelsAny {
placeholders[i] = "?"
}
whereClauses = append(whereClauses, fmt.Sprintf(`
EXISTS (
SELECT 1 FROM labels
WHERE issue_id = i.id AND label IN (%s)
)
`, strings.Join(placeholders, ",")))
for _, label := range filter.LabelsAny {
args = append(args, label)
}
}
// Build WHERE clause properly
whereSQL := strings.Join(whereClauses, " AND ")
// Build LIMIT clause using parameter
limitSQL := ""
if filter.Limit > 0 {
limitSQL = " LIMIT ?"
args = append(args, filter.Limit)
}
// Default to hybrid sort for backwards compatibility
sortPolicy := filter.SortPolicy
if sortPolicy == "" {
sortPolicy = types.SortPolicyHybrid
}
orderBySQL := buildOrderByClause(sortPolicy)
// Use blocked_issues_cache for performance (bd-5qim)
// Cache is maintained by invalidateBlockedCache() called on dependency/status changes
// #nosec G201 - safe SQL with controlled formatting
query := fmt.Sprintf(`
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo
FROM issues i
WHERE %s
AND NOT EXISTS (
SELECT 1 FROM blocked_issues_cache WHERE issue_id = i.id
)
%s
%s
`, whereSQL, orderBySQL, limitSQL)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to get ready work: %w", err)
}
defer func() { _ = rows.Close() }()
return s.scanIssues(ctx, rows)
}
// GetStaleIssues returns issues that haven't been updated recently
func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
// Build query with optional status filter
query := `
SELECT
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo,
compaction_level, compacted_at, compacted_at_commit, original_size
FROM issues
WHERE status != 'closed'
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
`
args := []interface{}{filter.Days}
// Add optional status filter
if filter.Status != "" {
query += " AND status = ?"
args = append(args, filter.Status)
}
query += " ORDER BY updated_at ASC"
// Add limit
if filter.Limit > 0 {
query += " LIMIT ?"
args = append(args, filter.Limit)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query stale issues: %w", err)
}
defer func() { _ = rows.Close() }()
var issues []*types.Issue
for rows.Next() {
var issue types.Issue
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var externalRef sql.NullString
var sourceRepo sql.NullString
var contentHash sql.NullString
var compactionLevel sql.NullInt64
var compactedAt sql.NullTime
var compactedAtCommit sql.NullString
var originalSize sql.NullInt64
err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&compactionLevel, &compactedAt, &compactedAtCommit, &originalSize,
)
if err != nil {
return nil, fmt.Errorf("failed to scan stale issue: %w", err)
}
if contentHash.Valid {
issue.ContentHash = contentHash.String
}
if closedAt.Valid {
issue.ClosedAt = &closedAt.Time
}
if estimatedMinutes.Valid {
mins := int(estimatedMinutes.Int64)
issue.EstimatedMinutes = &mins
}
if assignee.Valid {
issue.Assignee = assignee.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
if sourceRepo.Valid {
issue.SourceRepo = sourceRepo.String
}
if compactionLevel.Valid {
issue.CompactionLevel = int(compactionLevel.Int64)
}
if compactedAt.Valid {
issue.CompactedAt = &compactedAt.Time
}
if compactedAtCommit.Valid {
issue.CompactedAtCommit = &compactedAtCommit.String
}
if originalSize.Valid {
issue.OriginalSize = int(originalSize.Int64)
}
issues = append(issues, &issue)
}
return issues, rows.Err()
}
// GetBlockedIssues returns issues that are blocked by dependencies
func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
// Use GROUP_CONCAT to get all blocker IDs in a single query (no N+1)
rows, err := s.db.QueryContext(ctx, `
SELECT
i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
COUNT(d.depends_on_id) as blocked_by_count,
GROUP_CONCAT(d.depends_on_id, ',') as blocker_ids
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE i.status IN ('open', 'in_progress', 'blocked')
AND d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
GROUP BY i.id
ORDER BY i.priority ASC
`)
if err != nil {
return nil, fmt.Errorf("failed to get blocked issues: %w", err)
}
defer func() { _ = rows.Close() }()
var blocked []*types.BlockedIssue
for rows.Next() {
var issue types.BlockedIssue
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var externalRef sql.NullString
var sourceRepo sql.NullString
var blockerIDsStr string
err := rows.Scan(
&issue.ID, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &issue.BlockedByCount,
&blockerIDsStr,
)
if err != nil {
return nil, fmt.Errorf("failed to scan blocked issue: %w", err)
}
if closedAt.Valid {
issue.ClosedAt = &closedAt.Time
}
if estimatedMinutes.Valid {
mins := int(estimatedMinutes.Int64)
issue.EstimatedMinutes = &mins
}
if assignee.Valid {
issue.Assignee = assignee.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
if sourceRepo.Valid {
issue.SourceRepo = sourceRepo.String
}
// Parse comma-separated blocker IDs
if blockerIDsStr != "" {
issue.BlockedBy = strings.Split(blockerIDsStr, ",")
}
blocked = append(blocked, &issue)
}
return blocked, nil
}
// buildOrderByClause generates the ORDER BY clause based on sort policy
func buildOrderByClause(policy types.SortPolicy) string {
switch policy {
case types.SortPolicyPriority:
return `ORDER BY i.priority ASC, i.created_at ASC`
case types.SortPolicyOldest:
return `ORDER BY i.created_at ASC`
case types.SortPolicyHybrid:
fallthrough
default:
return `ORDER BY
CASE
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN 0
ELSE 1
END ASC,
CASE
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN i.priority
ELSE NULL
END ASC,
CASE
WHEN datetime(i.created_at) < datetime('now', '-48 hours') THEN i.created_at
ELSE NULL
END ASC,
i.created_at ASC`
}
}