Add blocked_issues_cache table for GetReadyWork optimization
Introduces a materialized cache table to store blocked issue IDs, replacing the expensive recursive CTE computation that was causing ~752ms query times on 10K databases (bd-5qim). The cache is maintained via invalidation on dependency and status changes, reducing GetReadyWork from O(n²) recursive traversal to O(1) cache lookup. Technical details: - New blocked_issues_cache table with single issue_id column - ON DELETE CASCADE ensures automatic cleanup - Migration populates cache using existing recursive CTE logic - rebuildBlockedCache() fully rebuilds cache on invalidation - execer interface allows both *sql.DB and *sql.Tx usage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
71
internal/storage/sqlite/blocked_cache.go
Normal file
71
internal/storage/sqlite/blocked_cache.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ var migrationsList = []Migration{
|
|||||||
{"source_repo_column", migrations.MigrateSourceRepoColumn},
|
{"source_repo_column", migrations.MigrateSourceRepoColumn},
|
||||||
{"repo_mtimes_table", migrations.MigrateRepoMtimesTable},
|
{"repo_mtimes_table", migrations.MigrateRepoMtimesTable},
|
||||||
{"child_counters_table", migrations.MigrateChildCountersTable},
|
{"child_counters_table", migrations.MigrateChildCountersTable},
|
||||||
|
{"blocked_issues_cache", migrations.MigrateBlockedIssuesCache},
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrationInfo contains metadata about a migration for inspection
|
// 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",
|
"source_repo_column": "Adds source_repo column for multi-repo support",
|
||||||
"repo_mtimes_table": "Adds repo_mtimes table for multi-repo hydration caching",
|
"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",
|
"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 {
|
if desc, ok := descriptions[name]; ok {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user