GetReadyWork now lazily resolves external dependencies at query time: - External refs (external:project:capability) checked against target DB - Issues with unsatisfied external deps are filtered from ready list - Satisfaction = closed issue with provides:<capability> label in target Key changes: - Remove FK constraint on depends_on_id to allow external refs - Add migration 025 to drop FK and recreate views - Filter external deps in GetReadyWork, not in blocked_issues_cache - Add application-level validation for orphaned local deps - Comprehensive tests for external dep resolution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
161 lines
6.1 KiB
Go
161 lines
6.1 KiB
Go
// 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)
|
|
}
|