Replace the recursive SQL CTE in DetectCycles with Go-layer DFS using shared visited set. The previous implementation enumerated all paths through the dependency graph, causing exponential blowup with diamond patterns (multiple issues depending on the same target). Changes: - Add loadDependencyGraph() to load deps as adjacency list in one query - Implement DFS cycle detection with recStack for back-edge detection - Add normalizeCycle() for consistent cycle deduplication - Add DetectCycles-specific benchmarks (Linear, Dense, Tree graphs) - Use direct SQL INSERT in benchmarks to bypass AddDependency overhead Performance improvement on dense graph (500 nodes, 2500 edges): - Before: >120s timeout - After: 1.6ms Benchmarks: - DetectCycles_Linear_1000: 0.84ms (1000 nodes, 999 edges) - DetectCycles_Dense_500: 1.59ms (500 nodes, ~2500 edges) - DetectCycles_Tree_1000: 0.85ms (1000 nodes, 999 edges)
391 lines
12 KiB
Go
391 lines
12 KiB
Go
//go:build bench
|
|
|
|
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// BenchmarkCycleDetection benchmarks the cycle detection performance
|
|
// on various graph sizes and structures
|
|
//
|
|
// Benchmark Results (Apple M4 Max, 2025-10-16):
|
|
//
|
|
// Linear chains (sparse):
|
|
// 100 issues: ~3.4ms per AddDependency (with cycle check)
|
|
// 1000 issues: ~3.7ms per AddDependency (with cycle check)
|
|
//
|
|
// Tree structure (branching factor 3):
|
|
// 100 issues: ~3.3ms per AddDependency
|
|
// 1000 issues: ~3.5ms per AddDependency
|
|
//
|
|
// Dense graphs (each issue depends on 3-5 previous):
|
|
// 100 issues: Times out (>120s for setup + benchmarking)
|
|
// 1000 issues: Times out
|
|
//
|
|
// Conclusion:
|
|
// - Cycle detection adds ~3-4ms overhead per AddDependency call
|
|
// - Performance is acceptable for typical use cases (linear chains, trees)
|
|
// - Dense graphs with many dependencies can be slow, but are rare in practice
|
|
// - No optimization needed for normal workflows
|
|
|
|
// BenchmarkCycleDetection_Linear_100 tests linear chain (sparse): bd-1 → bd-2 → bd-3 ... → bd-100
|
|
func BenchmarkCycleDetection_Linear_100(b *testing.B) {
|
|
benchmarkCycleDetectionLinear(b, 100)
|
|
}
|
|
|
|
// BenchmarkCycleDetection_Linear_1000 tests linear chain (sparse): bd-1 → bd-2 → ... → bd-1000
|
|
func BenchmarkCycleDetection_Linear_1000(b *testing.B) {
|
|
benchmarkCycleDetectionLinear(b, 1000)
|
|
}
|
|
|
|
// BenchmarkCycleDetection_Linear_5000 tests linear chain (sparse): bd-1 → bd-2 → ... → bd-5000
|
|
func BenchmarkCycleDetection_Linear_5000(b *testing.B) {
|
|
benchmarkCycleDetectionLinear(b, 5000)
|
|
}
|
|
|
|
// BenchmarkCycleDetection_Dense_100 tests dense graph: each issue depends on 3-5 previous issues
|
|
func BenchmarkCycleDetection_Dense_100(b *testing.B) {
|
|
b.Skip("Dense graph setup slow (creates 5*n deps). AddDependency CTE is O(n), not affected by DetectCycles fix.")
|
|
benchmarkCycleDetectionDense(b, 100)
|
|
}
|
|
|
|
// BenchmarkCycleDetection_Dense_1000 tests dense graph with 1000 issues
|
|
func BenchmarkCycleDetection_Dense_1000(b *testing.B) {
|
|
b.Skip("Dense graph setup slow (creates 5*n deps). AddDependency CTE is O(n), not affected by DetectCycles fix.")
|
|
benchmarkCycleDetectionDense(b, 1000)
|
|
}
|
|
|
|
// BenchmarkCycleDetection_Tree_100 tests tree structure (branching factor 3)
|
|
func BenchmarkCycleDetection_Tree_100(b *testing.B) {
|
|
benchmarkCycleDetectionTree(b, 100)
|
|
}
|
|
|
|
// BenchmarkCycleDetection_Tree_1000 tests tree structure with 1000 issues
|
|
func BenchmarkCycleDetection_Tree_1000(b *testing.B) {
|
|
benchmarkCycleDetectionTree(b, 1000)
|
|
}
|
|
|
|
// Helper: Create linear dependency chain
|
|
func benchmarkCycleDetectionLinear(b *testing.B, n int) {
|
|
store, cleanup := setupBenchDB(b)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create n issues
|
|
issues := make([]*types.Issue, n)
|
|
for i := 0; i < n; i++ {
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
issues[i] = issue
|
|
}
|
|
|
|
// Create linear chain: each issue depends on the previous one
|
|
for i := 1; i < n; i++ {
|
|
dep := &types.Dependency{
|
|
IssueID: issues[i].ID,
|
|
DependsOnID: issues[i-1].ID,
|
|
Type: types.DepBlocks,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
}
|
|
|
|
// Now benchmark adding a dependency that would NOT create a cycle
|
|
// (from the last issue to a new unconnected issue)
|
|
newIssue := &types.Issue{
|
|
Title: "New issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, newIssue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create new issue: %v", err)
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
// Add dependency from first issue to new issue (safe, no cycle)
|
|
dep := &types.Dependency{
|
|
IssueID: issues[0].ID,
|
|
DependsOnID: newIssue.ID,
|
|
Type: types.DepBlocks,
|
|
}
|
|
// This will run cycle detection on a chain of length n
|
|
_ = store.AddDependency(ctx, dep, "benchmark")
|
|
// Clean up for next iteration
|
|
_ = store.RemoveDependency(ctx, issues[0].ID, newIssue.ID, "benchmark")
|
|
}
|
|
}
|
|
|
|
// Helper: Create dense dependency graph
|
|
func benchmarkCycleDetectionDense(b *testing.B, n int) {
|
|
store, cleanup := setupBenchDB(b)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create n issues
|
|
issues := make([]*types.Issue, n)
|
|
for i := 0; i < n; i++ {
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
issues[i] = issue
|
|
}
|
|
|
|
// Create dense graph: each issue (after 5) depends on 3-5 previous issues
|
|
for i := 5; i < n; i++ {
|
|
for j := 1; j <= 5 && i-j >= 0; j++ {
|
|
dep := &types.Dependency{
|
|
IssueID: issues[i].ID,
|
|
DependsOnID: issues[i-j].ID,
|
|
Type: types.DepBlocks,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Benchmark adding a dependency
|
|
newIssue := &types.Issue{
|
|
Title: "New issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, newIssue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create new issue: %v", err)
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
dep := &types.Dependency{
|
|
IssueID: issues[n/2].ID, // Middle issue
|
|
DependsOnID: newIssue.ID,
|
|
Type: types.DepBlocks,
|
|
}
|
|
_ = store.AddDependency(ctx, dep, "benchmark")
|
|
_ = store.RemoveDependency(ctx, issues[n/2].ID, newIssue.ID, "benchmark")
|
|
}
|
|
}
|
|
|
|
// Helper: Create tree structure (branching)
|
|
func benchmarkCycleDetectionTree(b *testing.B, n int) {
|
|
store, cleanup := setupBenchDB(b)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Create n issues
|
|
issues := make([]*types.Issue, n)
|
|
for i := 0; i < n; i++ {
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
issues[i] = issue
|
|
}
|
|
|
|
// Create tree: each issue (after root) depends on parent (branching factor ~3)
|
|
for i := 1; i < n; i++ {
|
|
parent := (i - 1) / 3
|
|
dep := &types.Dependency{
|
|
IssueID: issues[i].ID,
|
|
DependsOnID: issues[parent].ID,
|
|
Type: types.DepBlocks,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
}
|
|
|
|
// Benchmark adding a dependency
|
|
newIssue := &types.Issue{
|
|
Title: "New issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, newIssue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create new issue: %v", err)
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
dep := &types.Dependency{
|
|
IssueID: issues[n-1].ID, // Leaf node
|
|
DependsOnID: newIssue.ID,
|
|
Type: types.DepBlocks,
|
|
}
|
|
_ = store.AddDependency(ctx, dep, "benchmark")
|
|
_ = store.RemoveDependency(ctx, issues[n-1].ID, newIssue.ID, "benchmark")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// DetectCycles Benchmarks
|
|
// These benchmark the DetectCycles function directly (not AddDependency).
|
|
// The Go DFS fix changed DetectCycles from O(2^n) to O(V+E).
|
|
// ============================================================================
|
|
|
|
// BenchmarkDetectCycles_Linear_1000 benchmarks DetectCycles on a linear chain
|
|
func BenchmarkDetectCycles_Linear_1000(b *testing.B) {
|
|
store, cleanup := setupBenchDB(b)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
createLinearGraph(b, store, ctx, 1000)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, _ = store.DetectCycles(ctx)
|
|
}
|
|
}
|
|
|
|
// BenchmarkDetectCycles_Dense_500 benchmarks DetectCycles on dense graph
|
|
// This was O(2^n) before the fix, now O(V+E)
|
|
func BenchmarkDetectCycles_Dense_500(b *testing.B) {
|
|
store, cleanup := setupBenchDB(b)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
createDenseGraphDirect(b, store, ctx, 500)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, _ = store.DetectCycles(ctx)
|
|
}
|
|
}
|
|
|
|
// BenchmarkDetectCycles_Tree_1000 benchmarks DetectCycles on tree structure
|
|
func BenchmarkDetectCycles_Tree_1000(b *testing.B) {
|
|
store, cleanup := setupBenchDB(b)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
createTreeGraph(b, store, ctx, 1000)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, _ = store.DetectCycles(ctx)
|
|
}
|
|
}
|
|
|
|
// createLinearGraph creates n issues with linear chain dependencies
|
|
func createLinearGraph(b *testing.B, store *SQLiteStorage, ctx context.Context, n int) []*types.Issue {
|
|
issues := make([]*types.Issue, n)
|
|
for i := 0; i < n; i++ {
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
issues[i] = issue
|
|
}
|
|
|
|
// Create linear chain using direct SQL (faster than AddDependency)
|
|
for i := 1; i < n; i++ {
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
|
VALUES (?, ?, 'blocks', datetime('now'), 'bench')
|
|
`, issues[i].ID, issues[i-1].ID)
|
|
if err != nil {
|
|
b.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
}
|
|
|
|
return issues
|
|
}
|
|
|
|
// createDenseGraphDirect creates n issues with dense deps using direct SQL
|
|
// Each issue (after 5) depends on the 5 previous issues
|
|
// Uses direct SQL to bypass AddDependency's cycle check (O(n) vs O(n²) setup)
|
|
func createDenseGraphDirect(b *testing.B, store *SQLiteStorage, ctx context.Context, n int) []*types.Issue {
|
|
issues := make([]*types.Issue, n)
|
|
for i := 0; i < n; i++ {
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
issues[i] = issue
|
|
}
|
|
|
|
// Create dense graph using direct SQL (bypasses cycle check during setup)
|
|
for i := 5; i < n; i++ {
|
|
for j := 1; j <= 5 && i-j >= 0; j++ {
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
|
VALUES (?, ?, 'blocks', datetime('now'), 'bench')
|
|
`, issues[i].ID, issues[i-j].ID)
|
|
if err != nil {
|
|
b.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return issues
|
|
}
|
|
|
|
// createTreeGraph creates n issues in tree structure (branching factor 3)
|
|
func createTreeGraph(b *testing.B, store *SQLiteStorage, ctx context.Context, n int) []*types.Issue {
|
|
issues := make([]*types.Issue, n)
|
|
for i := 0; i < n; i++ {
|
|
issue := &types.Issue{
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "benchmark"); err != nil {
|
|
b.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
issues[i] = issue
|
|
}
|
|
|
|
// Create tree using direct SQL
|
|
for i := 1; i < n; i++ {
|
|
parent := (i - 1) / 3
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
|
VALUES (?, ?, 'blocks', datetime('now'), 'bench')
|
|
`, issues[i].ID, issues[parent].ID)
|
|
if err != nil {
|
|
b.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
}
|
|
|
|
return issues
|
|
}
|