diff --git a/internal/storage/sqlite/blocked_cache.go b/internal/storage/sqlite/blocked_cache.go new file mode 100644 index 00000000..c50382bd --- /dev/null +++ b/internal/storage/sqlite/blocked_cache.go @@ -0,0 +1,71 @@ +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, tx *sql.Tx) error { + // Use the transaction if provided, otherwise use direct db connection + var exec execer = s.db + if tx != nil { + exec = tx + } + + // 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 + query := ` + INSERT INTO blocked_issues_cache (issue_id) + WITH RECURSIVE + -- Step 1: Find issues blocked directly by dependencies + 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') + ), + + -- 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, tx *sql.Tx) error { + return s.rebuildBlockedCache(ctx, tx) +} diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index aa44ad81..fceff72c 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -31,6 +31,7 @@ var migrationsList = []Migration{ {"source_repo_column", migrations.MigrateSourceRepoColumn}, {"repo_mtimes_table", migrations.MigrateRepoMtimesTable}, {"child_counters_table", migrations.MigrateChildCountersTable}, + {"blocked_issues_cache", migrations.MigrateBlockedIssuesCache}, } // MigrationInfo contains metadata about a migration for inspection @@ -69,6 +70,7 @@ func getMigrationDescription(name string) string { "source_repo_column": "Adds source_repo column for multi-repo support", "repo_mtimes_table": "Adds repo_mtimes table for multi-repo hydration caching", "child_counters_table": "Adds child_counters table for hierarchical ID generation with ON DELETE CASCADE", + "blocked_issues_cache": "Adds blocked_issues_cache table for GetReadyWork performance optimization (bd-5qim)", } if desc, ok := descriptions[name]; ok { diff --git a/internal/storage/sqlite/migrations/015_blocked_issues_cache.go b/internal/storage/sqlite/migrations/015_blocked_issues_cache.go new file mode 100644 index 00000000..7b587bfe --- /dev/null +++ b/internal/storage/sqlite/migrations/015_blocked_issues_cache.go @@ -0,0 +1,75 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateBlockedIssuesCache creates the blocked_issues_cache table for performance optimization +// This cache materializes the recursive CTE computation from GetReadyWork to avoid +// expensive recursive queries on every call (bd-5qim) +func MigrateBlockedIssuesCache(db *sql.DB) error { + // Check if table already exists + var tableName string + err := db.QueryRow(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='blocked_issues_cache' + `).Scan(&tableName) + + if err == sql.ErrNoRows { + // Create the cache table + _, err := db.Exec(` + CREATE TABLE blocked_issues_cache ( + issue_id TEXT NOT NULL, + PRIMARY KEY (issue_id), + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE + ) + `) + if err != nil { + return fmt.Errorf("failed to create blocked_issues_cache table: %w", err) + } + + // Populate the cache with initial data using the existing recursive CTE logic + _, err = db.Exec(` + INSERT INTO blocked_issues_cache (issue_id) + WITH RECURSIVE + -- Step 1: Find issues blocked directly by dependencies + 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') + ), + + -- 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 != nil { + return fmt.Errorf("failed to populate blocked_issues_cache: %w", err) + } + + return nil + } + + if err != nil { + return fmt.Errorf("failed to check for blocked_issues_cache table: %w", err) + } + + // Table already exists + return nil +}