// Package sqlite provides the blocked_issues_cache optimization for GetReadyWork performance. // // # Performance Impact // // GetReadyWork originally used a recursive CTE to compute blocked issues on every query, // taking ~752ms on a 10K issue database. With the cache, queries complete in ~29ms // (25x speedup) by using a simple NOT EXISTS check against the materialized cache table. // // # Cache Architecture // // The blocked_issues_cache table stores issue_id values for all issues that are currently // blocked. An issue is blocked if: // - It has a 'blocks' dependency on an open/in_progress/blocked issue (direct blocking) // - It has a 'blocks' dependency on an external:* reference (cross-project blocking, bd-om4a) // - Its parent is blocked and it's connected via 'parent-child' dependency (transitive blocking) // // The cache is maintained automatically by invalidating and rebuilding whenever: // - A 'blocks' or 'parent-child' dependency is added or removed // - Any issue's status changes (affects whether it blocks others) // - An issue is closed (closed issues don't block others) // // Related and discovered-from dependencies do NOT trigger cache invalidation since they // don't affect blocking semantics. // // # Cache Invalidation Strategy // // On any triggering change, the entire cache is rebuilt from scratch (DELETE + INSERT). // This full-rebuild approach is chosen because: // - Rebuild is fast (<50ms even on 10K databases) due to optimized CTE logic // - Simpler implementation than incremental updates // - Dependency changes are rare compared to reads // - Guarantees consistency - no risk of partial/stale updates // // The rebuild happens within the same transaction as the triggering change, ensuring // atomicity and consistency. The cache can never be in an inconsistent state visible // to queries. // // # Transaction Safety // // All cache operations support both transaction and direct database execution: // - rebuildBlockedCache accepts optional *sql.Tx parameter // - If tx != nil, uses transaction; otherwise uses direct db connection // - Cache invalidation during CreateIssue/UpdateIssue/AddDependency happens in their tx // - Ensures cache is always consistent with the database state // // # Performance Characteristics // // Query performance (GetReadyWork): // - Before cache: ~752ms (recursive CTE on 10K issues) // - With cache: ~29ms (NOT EXISTS check) // - Speedup: 25x // // Write overhead: // - Cache rebuild: <50ms (full DELETE + INSERT) // - Only triggered on dependency/status changes (rare operations) // - Trade-off: slower writes for much faster reads // // # Edge Cases Handled // // 1. Parent-child transitive blocking: // - Children of blocked parents are automatically marked as blocked // - Propagates through arbitrary depth hierarchies (limited to depth 50) // // 2. Multiple blockers: // - Issue blocked by multiple open issues stays blocked until all are closed // - DISTINCT in CTE ensures issue appears once in cache // // 3. Status changes: // - Closing a blocker removes all blocked descendants from cache // - Reopening a blocker adds them back // // 4. Dependency removal: // - Removing last blocker unblocks the issue // - Removing parent-child link unblocks orphaned subtree // // 5. Foreign key cascades: // - Cache entries automatically deleted when issue is deleted (ON DELETE CASCADE) // - No manual cleanup needed // // # Future Optimizations // // If rebuild becomes a bottleneck in very large databases (>100K issues): // - Consider incremental updates for specific dependency types // - Add indexes to dependencies table for CTE performance // - Implement dirty tracking to avoid rebuilds when cache is unchanged // // However, current performance is excellent for realistic workloads. package sqlite import ( "context" "database/sql" "fmt" ) // execer is an interface for types that can execute SQL queries // Both *sql.DB and *sql.Tx implement this interface type execer interface { ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) } // rebuildBlockedCache completely rebuilds the blocked_issues_cache table // This is used during cache invalidation when dependencies change func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) error { // Use direct db connection if no execer provided if exec == nil { exec = s.db } // Clear the cache if _, err := exec.ExecContext(ctx, "DELETE FROM blocked_issues_cache"); err != nil { return fmt.Errorf("failed to clear blocked_issues_cache: %w", err) } // Rebuild using the recursive CTE logic // Only includes local blockers (open issues) - external refs are resolved // lazily at query time by GetReadyWork (bd-zmmy supersedes bd-om4a) query := ` INSERT INTO blocked_issues_cache (issue_id) WITH RECURSIVE -- Step 1: Find issues blocked directly by LOCAL dependencies -- External refs (external:*) are excluded - they're resolved lazily by GetReadyWork blocked_directly AS ( SELECT DISTINCT d.issue_id FROM dependencies d JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.type = 'blocks' AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') ), -- Step 2: Propagate blockage to all descendants via parent-child blocked_transitively AS ( -- Base case: directly blocked issues SELECT issue_id, 0 as depth FROM blocked_directly UNION ALL -- Recursive case: children of blocked issues inherit blockage SELECT d.issue_id, bt.depth + 1 FROM blocked_transitively bt JOIN dependencies d ON d.depends_on_id = bt.issue_id WHERE d.type = 'parent-child' AND bt.depth < 50 ) SELECT DISTINCT issue_id FROM blocked_transitively ` if _, err := exec.ExecContext(ctx, query); err != nil { return fmt.Errorf("failed to rebuild blocked_issues_cache: %w", err) } return nil } // invalidateBlockedCache rebuilds the blocked issues cache // Called when dependencies change or issue status changes func (s *SQLiteStorage) invalidateBlockedCache(ctx context.Context, exec execer) error { return s.rebuildBlockedCache(ctx, exec) }