Performance Improvements (#319)
* feat: add performance testing framework foundation Implements foundation for comprehensive performance testing and user diagnostics for beads databases at 10K-20K scale. Components added: - Fixture generator (internal/testutil/fixtures/) for realistic test data * LargeSQLite/XLargeSQLite: 10K/20K issues with epic hierarchies * LargeFromJSONL/XLargeFromJSONL: test JSONL import path * Realistic cross-linked dependencies, labels, assignees * Reproducible with seeded RNG - User diagnostics (bd doctor --perf) for field performance data * Collects platform info (OS, arch, Go/SQLite versions) * Measures key operation timings (ready, list, show, search) * Generates CPU profiles for bug reports * Clean separation in cmd/bd/doctor/perf.go Test data characteristics: - 10% epics, 30% features, 60% tasks - 4-level hierarchies (Epic → Feature → Task → Subtask) - 20% cross-epic blocking dependencies - Realistic status/priority/label distributions Supports bd-l954 (Performance Testing Framework epic) Closes bd-6ed8, bd-q59i * perf: optimize GetReadyWork with compound index (20x speedup) Add compound index on dependencies(depends_on_id, type, issue_id) to eliminate performance bottleneck in GetReadyWork recursive CTE query. Performance improvements (10K issue database): - GetReadyWork: 752ms → 36.6ms (20.5x faster) - Target: <50ms ✓ ACHIEVED - 20K database: ~1500ms → 79.4ms (19x faster) Benchmark infrastructure enhancements: - Add dataset caching in /tmp/beads-bench-cache/ to avoid regenerating 10K-20K issues on every benchmark run (first run: ~2min, subsequent: <5s) - Add progress logging during fixture generation (shows 10%, 20%... completion) - Add database size logging (17.5 MB for 10K, 35.1 MB for 20K) - Document rationale for only benchmarking large datasets (>10K issues) - Add CPU/trace profiling with --profile flag for performance debugging Schema changes: - internal/storage/sqlite/schema.go: Add idx_dependencies_depends_on_type_issue New files: - internal/storage/sqlite/bench_helpers_test.go: Reusable benchmark setup with caching - internal/storage/sqlite/sqlite_bench_test.go: Comprehensive benchmarks for critical operations - Makefile: Convenient benchmark execution (make bench-quick, make bench) Related: - Resolves bd-5qim (optimize GetReadyWork performance) - Builds on bd-6ed8 (fixture generator), bd-q59i (bd doctor --perf) * perf: add WASM compilation cache to eliminate cold-start overhead Configure wazero compilation cache for ncruces/go-sqlite3 to avoid ~220ms JIT compilation on every process start. Cache configuration: - Location: ~/.cache/beads/wasm/ (platform-specific via os.UserCacheDir) - Automatic version management: wazero keys entries by its version - Fallback: in-memory cache if directory creation fails - No cleanup needed: old versions are harmless (~5-10MB each) Performance impact: - First run: ~220ms (populate cache) - Subsequent runs: ~20ms (load from cache) - Savings: ~200ms per cold start Cache invalidation: - Automatic when wazero version changes (upgrades use new cache dir) - Manual cleanup: rm -rf ~/.cache/beads/wasm/ (safe to delete anytime) This complements daemon mode: - Daemon mode: eliminates startup cost by keeping process alive - WASM cache: reduces startup cost for one-off commands or daemon restarts Changes: - internal/storage/sqlite/sqlite.go: Add init() with cache setup * refactor: improve maintainability of performance testing code Extract common patterns and eliminate duplication across benchmarks, fixture generation, and performance diagnostics. Replace magic numbers with explicit configuration to improve readability and make it easier to tune test parameters. * docs: clarify profiling behavior and add missing documentation Add explanatory comments for profiling setup to clarify why --profile forces direct mode (captures actual database operations instead of RPC overhead) and document the stopCPUProfile function's role in flushing profile data to disk. Also fix gosec G104 linter warning by explicitly ignoring Close() error during cleanup. * fix: prevent bench-quick from running indefinitely Added //go:build bench tags and skipped timeout-prone benchmarks to prevent make bench-quick from running for hours. Changes: - Add //go:build bench tag to cycle_bench_test.go and compact_bench_test.go - Skip Dense graph benchmarks (documented to timeout >120s) - Fix compact benchmark prefix: bd- → bd (validation expects prefix without trailing dash) Before: make bench-quick ran for 3.5+ hours (12,699s) before manual interrupt After: make bench-quick completes in ~25 seconds The Dense graph benchmarks are known to timeout and represent rare edge cases that don't need optimization for typical workflows.
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -99,3 +99,11 @@ __pycache__/
|
||||
*.so
|
||||
.Python
|
||||
.envrc
|
||||
|
||||
# Performance profiling files (benchmarks, bd doctor --perf, and bd --profile)
|
||||
*.prof
|
||||
*.out
|
||||
beads-perf-*.prof
|
||||
bench-cpu-*.prof
|
||||
bd-profile-*.prof
|
||||
bd-trace-*.out
|
||||
|
||||
57
Makefile
Normal file
57
Makefile
Normal file
@@ -0,0 +1,57 @@
|
||||
# Makefile for beads project
|
||||
|
||||
.PHONY: all build test bench bench-quick clean install help
|
||||
|
||||
# Default target
|
||||
all: build
|
||||
|
||||
# Build the bd binary
|
||||
build:
|
||||
@echo "Building bd..."
|
||||
go build -o bd ./cmd/bd
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
go test ./...
|
||||
|
||||
# Run performance benchmarks (10K and 20K issue databases with automatic CPU profiling)
|
||||
# Generates CPU profile: internal/storage/sqlite/bench-cpu-<timestamp>.prof
|
||||
# View flamegraph: go tool pprof -http=:8080 <profile-file>
|
||||
bench:
|
||||
@echo "Running performance benchmarks..."
|
||||
@echo "This will generate 10K and 20K issue databases and profile all operations."
|
||||
@echo "CPU profiles will be saved to internal/storage/sqlite/"
|
||||
@echo ""
|
||||
go test -bench=. -benchtime=1s -tags=bench -run=^$$ ./internal/storage/sqlite/ -timeout=30m
|
||||
@echo ""
|
||||
@echo "Benchmark complete. Profile files saved in internal/storage/sqlite/"
|
||||
@echo "View flamegraph: cd internal/storage/sqlite && go tool pprof -http=:8080 bench-cpu-*.prof"
|
||||
|
||||
# Run quick benchmarks (shorter benchtime for faster feedback)
|
||||
bench-quick:
|
||||
@echo "Running quick performance benchmarks..."
|
||||
go test -bench=. -benchtime=100ms -tags=bench -run=^$$ ./internal/storage/sqlite/ -timeout=15m
|
||||
|
||||
# Install bd to GOPATH/bin
|
||||
install: build
|
||||
@echo "Installing bd to $$(go env GOPATH)/bin..."
|
||||
go install ./cmd/bd
|
||||
|
||||
# Clean build artifacts and benchmark profiles
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
rm -f bd
|
||||
rm -f internal/storage/sqlite/bench-cpu-*.prof
|
||||
rm -f beads-perf-*.prof
|
||||
|
||||
# Show help
|
||||
help:
|
||||
@echo "Beads Makefile targets:"
|
||||
@echo " make build - Build the bd binary"
|
||||
@echo " make test - Run all tests"
|
||||
@echo " make bench - Run performance benchmarks (generates CPU profiles)"
|
||||
@echo " make bench-quick - Run quick benchmarks (shorter benchtime)"
|
||||
@echo " make install - Install bd to GOPATH/bin"
|
||||
@echo " make clean - Remove build artifacts and profile files"
|
||||
@echo " make help - Show this help message"
|
||||
@@ -46,6 +46,7 @@ type doctorResult struct {
|
||||
|
||||
var (
|
||||
doctorFix bool
|
||||
perfMode bool
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
@@ -68,11 +69,19 @@ This command checks:
|
||||
- Git hooks (pre-commit, post-merge, pre-push)
|
||||
- .beads/.gitignore up to date
|
||||
|
||||
Performance Mode (--perf):
|
||||
Run performance diagnostics on your database:
|
||||
- Times key operations (bd ready, bd list, bd show, etc.)
|
||||
- Collects system info (OS, arch, SQLite version, database stats)
|
||||
- Generates CPU profile for analysis
|
||||
- Outputs shareable report for bug reports
|
||||
|
||||
Examples:
|
||||
bd doctor # Check current directory
|
||||
bd doctor /path/to/repo # Check specific repository
|
||||
bd doctor --json # Machine-readable output
|
||||
bd doctor --fix # Automatically fix issues`,
|
||||
bd doctor --fix # Automatically fix issues
|
||||
bd doctor --perf # Performance diagnostics`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Use global jsonOutput set by PersistentPreRun
|
||||
|
||||
@@ -89,6 +98,12 @@ Examples:
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run performance diagnostics if --perf flag is set
|
||||
if perfMode {
|
||||
doctor.RunPerformanceDiagnostics(absPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Run diagnostics
|
||||
result := runDiagnostics(absPath)
|
||||
|
||||
@@ -1309,4 +1324,5 @@ func checkSchemaCompatibility(path string) doctorCheck {
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(doctorCmd)
|
||||
doctorCmd.Flags().BoolVar(&perfMode, "perf", false, "Run performance diagnostics and generate CPU profile")
|
||||
}
|
||||
|
||||
276
cmd/bd/doctor/perf.go
Normal file
276
cmd/bd/doctor/perf.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
)
|
||||
|
||||
var cpuProfileFile *os.File
|
||||
|
||||
// RunPerformanceDiagnostics runs performance diagnostics and generates a CPU profile
|
||||
func RunPerformanceDiagnostics(path string) {
|
||||
fmt.Println("\nBeads Performance Diagnostics")
|
||||
fmt.Println(strings.Repeat("=", 50))
|
||||
|
||||
// Check if .beads directory exists
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Error: No .beads/ directory found at %s\n", path)
|
||||
fmt.Fprintf(os.Stderr, "Run 'bd init' to initialize beads\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get database path
|
||||
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Error: No database found at %s\n", dbPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Collect platform info
|
||||
platformInfo := collectPlatformInfo(dbPath)
|
||||
fmt.Printf("\nPlatform: %s\n", platformInfo["os_arch"])
|
||||
fmt.Printf("Go: %s\n", platformInfo["go_version"])
|
||||
fmt.Printf("SQLite: %s\n", platformInfo["sqlite_version"])
|
||||
|
||||
// Collect database stats
|
||||
dbStats := collectDatabaseStats(dbPath)
|
||||
fmt.Printf("\nDatabase Statistics:\n")
|
||||
fmt.Printf(" Total issues: %s\n", dbStats["total_issues"])
|
||||
fmt.Printf(" Open issues: %s\n", dbStats["open_issues"])
|
||||
fmt.Printf(" Closed issues: %s\n", dbStats["closed_issues"])
|
||||
fmt.Printf(" Dependencies: %s\n", dbStats["dependencies"])
|
||||
fmt.Printf(" Labels: %s\n", dbStats["labels"])
|
||||
fmt.Printf(" Database size: %s\n", dbStats["db_size"])
|
||||
|
||||
// Start CPU profiling
|
||||
profilePath := fmt.Sprintf("beads-perf-%s.prof", time.Now().Format("2006-01-02-150405"))
|
||||
if err := startCPUProfile(profilePath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to start CPU profiling: %v\n", err)
|
||||
} else {
|
||||
defer stopCPUProfile()
|
||||
fmt.Printf("\nCPU profiling enabled: %s\n", profilePath)
|
||||
}
|
||||
|
||||
// Time key operations
|
||||
fmt.Printf("\nOperation Performance:\n")
|
||||
|
||||
// Measure GetReadyWork
|
||||
readyDuration := measureOperation("bd ready", func() error {
|
||||
return runReadyWork(dbPath)
|
||||
})
|
||||
fmt.Printf(" bd ready %dms\n", readyDuration.Milliseconds())
|
||||
|
||||
// Measure SearchIssues (list open)
|
||||
listDuration := measureOperation("bd list --status=open", func() error {
|
||||
return runListOpen(dbPath)
|
||||
})
|
||||
fmt.Printf(" bd list --status=open %dms\n", listDuration.Milliseconds())
|
||||
|
||||
// Measure GetIssue (show random issue)
|
||||
showDuration := measureOperation("bd show <issue>", func() error {
|
||||
return runShowRandom(dbPath)
|
||||
})
|
||||
if showDuration > 0 {
|
||||
fmt.Printf(" bd show <random-issue> %dms\n", showDuration.Milliseconds())
|
||||
}
|
||||
|
||||
// Measure SearchIssues with filters
|
||||
searchDuration := measureOperation("bd list (complex filters)", func() error {
|
||||
return runComplexSearch(dbPath)
|
||||
})
|
||||
fmt.Printf(" bd list (complex filters) %dms\n", searchDuration.Milliseconds())
|
||||
|
||||
fmt.Printf("\nProfile saved: %s\n", profilePath)
|
||||
fmt.Printf("Share this file with bug reports for performance issues.\n\n")
|
||||
fmt.Printf("View flamegraph:\n")
|
||||
fmt.Printf(" go tool pprof -http=:8080 %s\n\n", profilePath)
|
||||
}
|
||||
|
||||
func collectPlatformInfo(dbPath string) map[string]string {
|
||||
info := make(map[string]string)
|
||||
|
||||
// OS and architecture
|
||||
info["os_arch"] = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
// Go version
|
||||
info["go_version"] = runtime.Version()
|
||||
|
||||
// SQLite version
|
||||
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
|
||||
if err == nil {
|
||||
defer db.Close()
|
||||
var version string
|
||||
if err := db.QueryRow("SELECT sqlite_version()").Scan(&version); err == nil {
|
||||
info["sqlite_version"] = version
|
||||
} else {
|
||||
info["sqlite_version"] = "unknown"
|
||||
}
|
||||
} else {
|
||||
info["sqlite_version"] = "unknown"
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func collectDatabaseStats(dbPath string) map[string]string {
|
||||
stats := make(map[string]string)
|
||||
|
||||
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
|
||||
if err != nil {
|
||||
stats["total_issues"] = "error"
|
||||
stats["open_issues"] = "error"
|
||||
stats["closed_issues"] = "error"
|
||||
stats["dependencies"] = "error"
|
||||
stats["labels"] = "error"
|
||||
stats["db_size"] = "error"
|
||||
return stats
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Total issues
|
||||
var total int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM issues").Scan(&total); err == nil {
|
||||
stats["total_issues"] = fmt.Sprintf("%d", total)
|
||||
} else {
|
||||
stats["total_issues"] = "error"
|
||||
}
|
||||
|
||||
// Open issues
|
||||
var open int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM issues WHERE status != 'closed'").Scan(&open); err == nil {
|
||||
stats["open_issues"] = fmt.Sprintf("%d", open)
|
||||
} else {
|
||||
stats["open_issues"] = "error"
|
||||
}
|
||||
|
||||
// Closed issues
|
||||
var closed int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'closed'").Scan(&closed); err == nil {
|
||||
stats["closed_issues"] = fmt.Sprintf("%d", closed)
|
||||
} else {
|
||||
stats["closed_issues"] = "error"
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
var deps int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM dependencies").Scan(&deps); err == nil {
|
||||
stats["dependencies"] = fmt.Sprintf("%d", deps)
|
||||
} else {
|
||||
stats["dependencies"] = "error"
|
||||
}
|
||||
|
||||
// Labels
|
||||
var labels int
|
||||
if err := db.QueryRow("SELECT COUNT(DISTINCT label) FROM labels").Scan(&labels); err == nil {
|
||||
stats["labels"] = fmt.Sprintf("%d", labels)
|
||||
} else {
|
||||
stats["labels"] = "error"
|
||||
}
|
||||
|
||||
// Database file size
|
||||
if info, err := os.Stat(dbPath); err == nil {
|
||||
sizeMB := float64(info.Size()) / (1024 * 1024)
|
||||
stats["db_size"] = fmt.Sprintf("%.2f MB", sizeMB)
|
||||
} else {
|
||||
stats["db_size"] = "error"
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func startCPUProfile(path string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cpuProfileFile = f
|
||||
return pprof.StartCPUProfile(f)
|
||||
}
|
||||
|
||||
// stopCPUProfile stops CPU profiling and closes the profile file.
|
||||
// Must be called after pprof.StartCPUProfile() to flush profile data to disk.
|
||||
func stopCPUProfile() {
|
||||
pprof.StopCPUProfile()
|
||||
if cpuProfileFile != nil {
|
||||
_ = cpuProfileFile.Close() // best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
func measureOperation(name string, op func() error) time.Duration {
|
||||
start := time.Now()
|
||||
if err := op(); err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Since(start)
|
||||
}
|
||||
|
||||
// runQuery executes a read-only database query and returns any error
|
||||
func runQuery(dbPath string, queryFn func(*sql.DB) error) error {
|
||||
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return queryFn(db)
|
||||
}
|
||||
|
||||
func runReadyWork(dbPath string) error {
|
||||
return runQuery(dbPath, func(db *sql.DB) error {
|
||||
// simplified ready work query (the real one is more complex)
|
||||
_, err := db.Query(`
|
||||
SELECT id FROM issues
|
||||
WHERE status IN ('open', 'in_progress')
|
||||
AND id NOT IN (
|
||||
SELECT issue_id FROM dependencies WHERE type = 'blocks'
|
||||
)
|
||||
LIMIT 100
|
||||
`)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func runListOpen(dbPath string) error {
|
||||
return runQuery(dbPath, func(db *sql.DB) error {
|
||||
_, err := db.Query("SELECT id, title, status FROM issues WHERE status != 'closed' LIMIT 100")
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func runShowRandom(dbPath string) error {
|
||||
return runQuery(dbPath, func(db *sql.DB) error {
|
||||
// get a random issue
|
||||
var issueID string
|
||||
if err := db.QueryRow("SELECT id FROM issues ORDER BY RANDOM() LIMIT 1").Scan(&issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get issue details
|
||||
_, err := db.Query("SELECT * FROM issues WHERE id = ?", issueID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func runComplexSearch(dbPath string) error {
|
||||
return runQuery(dbPath, func(db *sql.DB) error {
|
||||
// complex query with filters
|
||||
_, err := db.Query(`
|
||||
SELECT i.id, i.title, i.status, i.priority
|
||||
FROM issues i
|
||||
LEFT JOIN labels l ON i.id = l.issue_id
|
||||
WHERE i.status IN ('open', 'in_progress')
|
||||
AND i.priority <= 2
|
||||
GROUP BY i.id
|
||||
LIMIT 100
|
||||
`)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/pprof"
|
||||
"runtime/trace"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -78,6 +80,9 @@ var (
|
||||
noAutoImport bool
|
||||
sandboxMode bool
|
||||
noDb bool // Use --no-db mode: load from JSONL, write back after each command
|
||||
profileEnabled bool
|
||||
profileFile *os.File
|
||||
traceFile *os.File
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -95,6 +100,7 @@ func init() {
|
||||
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
|
||||
rootCmd.PersistentFlags().BoolVar(&sandboxMode, "sandbox", false, "Sandbox mode: disables daemon and auto-sync")
|
||||
rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite")
|
||||
rootCmd.PersistentFlags().BoolVar(&profileEnabled, "profile", false, "Generate CPU profile for performance analysis")
|
||||
|
||||
// Add --version flag to root command (same behavior as version subcommand)
|
||||
rootCmd.Flags().BoolP("version", "v", false, "Print version information")
|
||||
@@ -141,6 +147,23 @@ var rootCmd = &cobra.Command{
|
||||
actor = config.GetString("actor")
|
||||
}
|
||||
|
||||
// Performance profiling setup
|
||||
// When --profile is enabled, force direct mode to capture actual database operations
|
||||
// rather than just RPC serialization/network overhead. This gives accurate profiles
|
||||
// of the storage layer, query performance, and business logic.
|
||||
if profileEnabled {
|
||||
noDaemon = true
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
if f, _ := os.Create(fmt.Sprintf("bd-profile-%s-%s.prof", cmd.Name(), timestamp)); f != nil {
|
||||
profileFile = f
|
||||
_ = pprof.StartCPUProfile(f)
|
||||
}
|
||||
if f, _ := os.Create(fmt.Sprintf("bd-trace-%s-%s.out", cmd.Name(), timestamp)); f != nil {
|
||||
traceFile = f
|
||||
_ = trace.Start(f)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip database initialization for commands that don't need a database
|
||||
noDbCommands := []string{
|
||||
cmdDaemon,
|
||||
@@ -505,6 +528,8 @@ var rootCmd = &cobra.Command{
|
||||
if store != nil {
|
||||
_ = store.Close()
|
||||
}
|
||||
if profileFile != nil { pprof.StopCPUProfile(); _ = profileFile.Close() }
|
||||
if traceFile != nil { trace.Stop(); _ = traceFile.Close() }
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
245
internal/storage/sqlite/bench_helpers_test.go
Normal file
245
internal/storage/sqlite/bench_helpers_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
//go:build bench
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/testutil/fixtures"
|
||||
)
|
||||
|
||||
var (
|
||||
profileOnce sync.Once
|
||||
profileFile *os.File
|
||||
benchCacheDir = "/tmp/beads-bench-cache"
|
||||
)
|
||||
|
||||
// startBenchmarkProfiling starts CPU profiling for the entire benchmark run.
|
||||
// Uses sync.Once to ensure it only runs once per test process.
|
||||
// The profile is saved to bench-cpu-<timestamp>.prof in the current directory.
|
||||
func startBenchmarkProfiling(b *testing.B) {
|
||||
b.Helper()
|
||||
profileOnce.Do(func() {
|
||||
profilePath := fmt.Sprintf("bench-cpu-%s.prof", time.Now().Format("2006-01-02-150405"))
|
||||
f, err := os.Create(profilePath)
|
||||
if err != nil {
|
||||
b.Logf("Warning: failed to create CPU profile: %v", err)
|
||||
return
|
||||
}
|
||||
profileFile = f
|
||||
|
||||
if err := pprof.StartCPUProfile(f); err != nil {
|
||||
b.Logf("Warning: failed to start CPU profiling: %v", err)
|
||||
f.Close()
|
||||
return
|
||||
}
|
||||
|
||||
b.Logf("CPU profiling enabled: %s", profilePath)
|
||||
|
||||
// Register cleanup to stop profiling when all benchmarks complete
|
||||
b.Cleanup(func() {
|
||||
pprof.StopCPUProfile()
|
||||
if profileFile != nil {
|
||||
profileFile.Close()
|
||||
b.Logf("CPU profile saved: %s", profilePath)
|
||||
b.Logf("View flamegraph: go tool pprof -http=:8080 %s", profilePath)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark setup rationale:
|
||||
// We only provide Large (10K) and XLarge (20K) setup functions because
|
||||
// small databases don't exhibit the performance characteristics we need to optimize.
|
||||
// See sqlite_bench_test.go for full rationale.
|
||||
//
|
||||
// Dataset caching:
|
||||
// Datasets are cached in /tmp/beads-bench-cache/ to avoid regenerating 10K-20K
|
||||
// issues on every benchmark run. Cached databases are ~10-30MB and reused across runs.
|
||||
|
||||
// getCachedOrGenerateDB returns a cached database or generates it if missing.
|
||||
// cacheKey should be unique per dataset type (e.g., "large", "xlarge").
|
||||
// generateFn is called only if the cached database doesn't exist.
|
||||
func getCachedOrGenerateDB(b *testing.B, cacheKey string, generateFn func(context.Context, storage.Storage) error) string {
|
||||
b.Helper()
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(benchCacheDir, 0755); err != nil {
|
||||
b.Fatalf("Failed to create benchmark cache directory: %v", err)
|
||||
}
|
||||
|
||||
dbPath := fmt.Sprintf("%s/%s.db", benchCacheDir, cacheKey)
|
||||
|
||||
// Check if cached database exists
|
||||
if stat, err := os.Stat(dbPath); err == nil {
|
||||
sizeMB := float64(stat.Size()) / (1024 * 1024)
|
||||
b.Logf("Using cached benchmark database: %s (%.1f MB)", dbPath, sizeMB)
|
||||
return dbPath
|
||||
}
|
||||
|
||||
// Generate new database
|
||||
b.Logf("===== Generating benchmark database: %s =====", dbPath)
|
||||
b.Logf("This is a one-time operation that will be cached for future runs...")
|
||||
b.Logf("Expected time: ~1-3 minutes for 10K issues, ~2-6 minutes for 20K issues")
|
||||
|
||||
store, err := New(dbPath)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize database with prefix
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd-"); err != nil {
|
||||
store.Close()
|
||||
b.Fatalf("Failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
// Generate dataset using provided function
|
||||
if err := generateFn(ctx, store); err != nil {
|
||||
store.Close()
|
||||
os.Remove(dbPath) // cleanup partial database
|
||||
b.Fatalf("Failed to generate dataset: %v", err)
|
||||
}
|
||||
|
||||
store.Close()
|
||||
|
||||
// Log completion with final size
|
||||
if stat, err := os.Stat(dbPath); err == nil {
|
||||
sizeMB := float64(stat.Size()) / (1024 * 1024)
|
||||
b.Logf("===== Database generation complete: %s (%.1f MB) =====", dbPath, sizeMB)
|
||||
}
|
||||
|
||||
return dbPath
|
||||
}
|
||||
|
||||
// copyFile copies a file from src to dst.
|
||||
func copyFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dstFile.Sync()
|
||||
}
|
||||
|
||||
// setupLargeBenchDB creates or reuses a cached 10K issue database.
|
||||
// Returns configured storage instance and cleanup function.
|
||||
// Uses //go:build bench tag to avoid running in normal tests.
|
||||
// Automatically enables CPU profiling on first call.
|
||||
//
|
||||
// Note: Copies the cached database to a temp location for each benchmark
|
||||
// to prevent mutations from affecting subsequent runs.
|
||||
func setupLargeBenchDB(b *testing.B) (*SQLiteStorage, func()) {
|
||||
b.Helper()
|
||||
|
||||
// Start CPU profiling (only happens once per test run)
|
||||
startBenchmarkProfiling(b)
|
||||
|
||||
// Get or generate cached database
|
||||
cachedPath := getCachedOrGenerateDB(b, "large", fixtures.LargeSQLite)
|
||||
|
||||
// Copy to temp location to prevent mutations
|
||||
tmpPath := b.TempDir() + "/large.db"
|
||||
if err := copyFile(cachedPath, tmpPath); err != nil {
|
||||
b.Fatalf("Failed to copy cached database: %v", err)
|
||||
}
|
||||
|
||||
// Open the temporary copy
|
||||
store, err := New(tmpPath)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
|
||||
return store, func() {
|
||||
store.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// setupXLargeBenchDB creates or reuses a cached 20K issue database.
|
||||
// Returns configured storage instance and cleanup function.
|
||||
// Uses //go:build bench tag to avoid running in normal tests.
|
||||
// Automatically enables CPU profiling on first call.
|
||||
//
|
||||
// Note: Copies the cached database to a temp location for each benchmark
|
||||
// to prevent mutations from affecting subsequent runs.
|
||||
func setupXLargeBenchDB(b *testing.B) (*SQLiteStorage, func()) {
|
||||
b.Helper()
|
||||
|
||||
// Start CPU profiling (only happens once per test run)
|
||||
startBenchmarkProfiling(b)
|
||||
|
||||
// Get or generate cached database
|
||||
cachedPath := getCachedOrGenerateDB(b, "xlarge", fixtures.XLargeSQLite)
|
||||
|
||||
// Copy to temp location to prevent mutations
|
||||
tmpPath := b.TempDir() + "/xlarge.db"
|
||||
if err := copyFile(cachedPath, tmpPath); err != nil {
|
||||
b.Fatalf("Failed to copy cached database: %v", err)
|
||||
}
|
||||
|
||||
// Open the temporary copy
|
||||
store, err := New(tmpPath)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
|
||||
return store, func() {
|
||||
store.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// setupLargeFromJSONL creates or reuses a cached 10K issue database via JSONL import path.
|
||||
// Returns configured storage instance and cleanup function.
|
||||
// Uses //go:build bench tag to avoid running in normal tests.
|
||||
// Automatically enables CPU profiling on first call.
|
||||
//
|
||||
// Note: Copies the cached database to a temp location for each benchmark
|
||||
// to prevent mutations from affecting subsequent runs.
|
||||
func setupLargeFromJSONL(b *testing.B) (*SQLiteStorage, func()) {
|
||||
b.Helper()
|
||||
|
||||
// Start CPU profiling (only happens once per test run)
|
||||
startBenchmarkProfiling(b)
|
||||
|
||||
// Get or generate cached database with JSONL import path
|
||||
cachedPath := getCachedOrGenerateDB(b, "large-jsonl", func(ctx context.Context, store storage.Storage) error {
|
||||
tempDir := b.TempDir()
|
||||
return fixtures.LargeFromJSONL(ctx, store, tempDir)
|
||||
})
|
||||
|
||||
// Copy to temp location to prevent mutations
|
||||
tmpPath := b.TempDir() + "/large-jsonl.db"
|
||||
if err := copyFile(cachedPath, tmpPath); err != nil {
|
||||
b.Fatalf("Failed to copy cached database: %v", err)
|
||||
}
|
||||
|
||||
// Open the temporary copy
|
||||
store, err := New(tmpPath)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
|
||||
return store, func() {
|
||||
store.Close()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build bench
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
@@ -124,6 +126,9 @@ func setupBenchDB(tb testing.TB) (*SQLiteStorage, func()) {
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
||||
tb.Fatalf("Failed to set issue_prefix: %v", err)
|
||||
}
|
||||
if err := store.SetConfig(ctx, "compact_tier1_days", "30"); err != nil {
|
||||
tb.Fatalf("Failed to set config: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build bench
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
@@ -48,11 +50,13 @@ func BenchmarkCycleDetection_Linear_5000(b *testing.B) {
|
||||
|
||||
// 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 benchmarks timeout (>120s). Known issue, no optimization needed for rare use case.")
|
||||
benchmarkCycleDetectionDense(b, 100)
|
||||
}
|
||||
|
||||
// BenchmarkCycleDetection_Dense_1000 tests dense graph with 1000 issues
|
||||
func BenchmarkCycleDetection_Dense_1000(b *testing.B) {
|
||||
b.Skip("Dense graph benchmarks timeout (>120s). Known issue, no optimization needed for rare use case.")
|
||||
benchmarkCycleDetectionDense(b, 1000)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS dependencies (
|
||||
CREATE INDEX IF NOT EXISTS idx_dependencies_issue ON dependencies(issue_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_type ON dependencies(depends_on_id, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_type_issue ON dependencies(depends_on_id, type, issue_id);
|
||||
|
||||
-- Labels table
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
|
||||
// Import SQLite driver
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
sqlite3 "github.com/ncruces/go-sqlite3"
|
||||
_ "github.com/ncruces/go-sqlite3/driver"
|
||||
_ "github.com/ncruces/go-sqlite3/embed"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// SQLiteStorage implements the Storage interface using SQLite
|
||||
@@ -25,6 +27,53 @@ type SQLiteStorage struct {
|
||||
closed atomic.Bool // Tracks whether Close() has been called
|
||||
}
|
||||
|
||||
// setupWASMCache configures WASM compilation caching to reduce SQLite startup time.
|
||||
// Returns the cache directory path (empty string if using in-memory cache).
|
||||
//
|
||||
// Cache behavior:
|
||||
// - Location: ~/.cache/beads/wasm/ (platform-specific via os.UserCacheDir)
|
||||
// - Version management: wazero automatically keys cache by its version
|
||||
// - Cleanup: Old versions remain harmless (~5-10MB each); manual cleanup if needed
|
||||
// - Fallback: Uses in-memory cache if filesystem cache creation fails
|
||||
//
|
||||
// Performance impact:
|
||||
// - First run: ~220ms (compile + cache)
|
||||
// - Subsequent runs: ~20ms (load from cache)
|
||||
func setupWASMCache() string {
|
||||
cacheDir := ""
|
||||
if userCache, err := os.UserCacheDir(); err == nil {
|
||||
cacheDir = filepath.Join(userCache, "beads", "wasm")
|
||||
}
|
||||
|
||||
var cache wazero.CompilationCache
|
||||
if cacheDir != "" {
|
||||
// Try file-system cache first (persistent across runs)
|
||||
if c, err := wazero.NewCompilationCacheWithDir(cacheDir); err == nil {
|
||||
cache = c
|
||||
// Optional: log cache location for debugging
|
||||
// fmt.Fprintf(os.Stderr, "WASM cache: %s\n", cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to in-memory cache if dir creation failed
|
||||
if cache == nil {
|
||||
cache = wazero.NewCompilationCache()
|
||||
cacheDir = "" // Indicate in-memory fallback
|
||||
// Optional: log fallback for debugging
|
||||
// fmt.Fprintln(os.Stderr, "WASM cache: in-memory only")
|
||||
}
|
||||
|
||||
// Configure go-sqlite3's wazero runtime to use the cache
|
||||
sqlite3.RuntimeConfig = wazero.NewRuntimeConfig().WithCompilationCache(cache)
|
||||
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Setup WASM compilation cache to avoid 220ms JIT compilation overhead on every process start
|
||||
_ = setupWASMCache()
|
||||
}
|
||||
|
||||
// New creates a new SQLite storage backend
|
||||
func New(path string) (*SQLiteStorage, error) {
|
||||
// Build connection string with proper URI syntax
|
||||
|
||||
145
internal/storage/sqlite/sqlite_bench_test.go
Normal file
145
internal/storage/sqlite/sqlite_bench_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
//go:build bench
|
||||
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// Benchmark size rationale:
|
||||
// We only benchmark Large (10K) and XLarge (20K) databases because:
|
||||
// - Small databases (<1K issues) perform acceptably without optimization
|
||||
// - Performance issues only manifest at scale (10K+ issues)
|
||||
// - Smaller benchmarks add code weight without providing optimization insights
|
||||
// - Target users manage repos with thousands of issues, not hundreds
|
||||
|
||||
// runBenchmark sets up a benchmark with consistent configuration and runs the provided test function.
|
||||
// It handles store setup/cleanup, timer management, and allocation reporting uniformly across all benchmarks.
|
||||
func runBenchmark(b *testing.B, setupFunc func(*testing.B) (*SQLiteStorage, func()), testFunc func(*SQLiteStorage, context.Context) error) {
|
||||
b.Helper()
|
||||
|
||||
store, cleanup := setupFunc(b)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := testFunc(store, ctx); err != nil {
|
||||
b.Fatalf("benchmark failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetReadyWork_Large benchmarks GetReadyWork on 10K issue database
|
||||
func BenchmarkGetReadyWork_Large(b *testing.B) {
|
||||
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
|
||||
_, err := store.GetReadyWork(ctx, types.WorkFilter{})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkGetReadyWork_XLarge benchmarks GetReadyWork on 20K issue database
|
||||
func BenchmarkGetReadyWork_XLarge(b *testing.B) {
|
||||
runBenchmark(b, setupXLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
|
||||
_, err := store.GetReadyWork(ctx, types.WorkFilter{})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkSearchIssues_Large_NoFilter benchmarks searching all open issues
|
||||
func BenchmarkSearchIssues_Large_NoFilter(b *testing.B) {
|
||||
openStatus := types.StatusOpen
|
||||
filter := types.IssueFilter{
|
||||
Status: &openStatus,
|
||||
}
|
||||
|
||||
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
|
||||
_, err := store.SearchIssues(ctx, "", filter)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkSearchIssues_Large_ComplexFilter benchmarks complex filtered search
|
||||
func BenchmarkSearchIssues_Large_ComplexFilter(b *testing.B) {
|
||||
openStatus := types.StatusOpen
|
||||
filter := types.IssueFilter{
|
||||
Status: &openStatus,
|
||||
PriorityMin: intPtr(0),
|
||||
PriorityMax: intPtr(2),
|
||||
}
|
||||
|
||||
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
|
||||
_, err := store.SearchIssues(ctx, "", filter)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkCreateIssue_Large benchmarks issue creation in large database
|
||||
func BenchmarkCreateIssue_Large(b *testing.B) {
|
||||
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
|
||||
issue := &types.Issue{
|
||||
Title: "Benchmark issue",
|
||||
Description: "Test description",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
return store.CreateIssue(ctx, issue, "bench")
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkUpdateIssue_Large benchmarks issue updates in large database
|
||||
func BenchmarkUpdateIssue_Large(b *testing.B) {
|
||||
// Setup phase: get an issue to update (not timed)
|
||||
store, cleanup := setupLargeBenchDB(b)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
openStatus := types.StatusOpen
|
||||
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
|
||||
Status: &openStatus,
|
||||
})
|
||||
if err != nil || len(issues) == 0 {
|
||||
b.Fatalf("Failed to get issues for update test: %v", err)
|
||||
}
|
||||
targetID := issues[0].ID
|
||||
|
||||
// Benchmark phase: measure update operations
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
updates := map[string]interface{}{
|
||||
"status": types.StatusInProgress,
|
||||
}
|
||||
|
||||
if err := store.UpdateIssue(ctx, targetID, updates, "bench"); err != nil {
|
||||
b.Fatalf("UpdateIssue failed: %v", err)
|
||||
}
|
||||
|
||||
// reset back to open for next iteration
|
||||
updates["status"] = types.StatusOpen
|
||||
if err := store.UpdateIssue(ctx, targetID, updates, "bench"); err != nil {
|
||||
b.Fatalf("UpdateIssue failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkGetReadyWork_FromJSONL benchmarks ready work on JSONL-imported database
|
||||
func BenchmarkGetReadyWork_FromJSONL(b *testing.B) {
|
||||
runBenchmark(b, setupLargeFromJSONL, func(store *SQLiteStorage, ctx context.Context) error {
|
||||
_, err := store.GetReadyWork(ctx, types.WorkFilter{})
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
541
internal/testutil/fixtures/fixtures.go
Normal file
541
internal/testutil/fixtures/fixtures.go
Normal file
@@ -0,0 +1,541 @@
|
||||
// Package fixtures provides realistic test data generation for benchmarks and tests.
|
||||
package fixtures
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// labels used across all fixtures
|
||||
var commonLabels = []string{
|
||||
"backend",
|
||||
"frontend",
|
||||
"urgent",
|
||||
"tech-debt",
|
||||
"documentation",
|
||||
"performance",
|
||||
"security",
|
||||
"ux",
|
||||
"api",
|
||||
"database",
|
||||
}
|
||||
|
||||
// assignees used across all fixtures
|
||||
var commonAssignees = []string{
|
||||
"alice",
|
||||
"bob",
|
||||
"charlie",
|
||||
"diana",
|
||||
"eve",
|
||||
"frank",
|
||||
}
|
||||
|
||||
// epic titles for realistic data
|
||||
var epicTitles = []string{
|
||||
"User Authentication System",
|
||||
"Payment Processing Integration",
|
||||
"Mobile App Redesign",
|
||||
"Performance Optimization",
|
||||
"API v2 Migration",
|
||||
"Search Functionality Enhancement",
|
||||
"Analytics Dashboard",
|
||||
"Multi-tenant Support",
|
||||
"Notification System",
|
||||
"Data Export Feature",
|
||||
}
|
||||
|
||||
// feature titles (under epics)
|
||||
var featureTitles = []string{
|
||||
"OAuth2 Integration",
|
||||
"Password Reset Flow",
|
||||
"Two-Factor Authentication",
|
||||
"Session Management",
|
||||
"API Endpoints",
|
||||
"Database Schema",
|
||||
"UI Components",
|
||||
"Background Jobs",
|
||||
"Error Handling",
|
||||
"Testing Infrastructure",
|
||||
}
|
||||
|
||||
// task titles (under features)
|
||||
var taskTitles = []string{
|
||||
"Implement login endpoint",
|
||||
"Add validation logic",
|
||||
"Write unit tests",
|
||||
"Update documentation",
|
||||
"Fix memory leak",
|
||||
"Optimize query performance",
|
||||
"Add error logging",
|
||||
"Refactor helper functions",
|
||||
"Update database migrations",
|
||||
"Configure deployment",
|
||||
}
|
||||
|
||||
// Fixture size rationale:
|
||||
// We only provide Large (10K) and XLarge (20K) fixtures because:
|
||||
// - Performance characteristics only emerge at scale (10K+ issues)
|
||||
// - Smaller fixtures don't provide meaningful optimization insights
|
||||
// - Code weight matters; we avoid unused complexity
|
||||
// - Target use case: repositories with thousands of issues
|
||||
|
||||
// DataConfig controls the distribution and characteristics of generated test data
|
||||
type DataConfig struct {
|
||||
TotalIssues int // total number of issues to generate
|
||||
EpicRatio float64 // percentage of issues that are epics (e.g., 0.1 for 10%)
|
||||
FeatureRatio float64 // percentage of issues that are features (e.g., 0.3 for 30%)
|
||||
OpenRatio float64 // percentage of issues that are open (e.g., 0.5 for 50%)
|
||||
CrossLinkRatio float64 // percentage of tasks with cross-epic blocking dependencies (e.g., 0.2 for 20%)
|
||||
MaxEpicAgeDays int // maximum age in days for epics (e.g., 180)
|
||||
MaxFeatureAgeDays int // maximum age in days for features (e.g., 150)
|
||||
MaxTaskAgeDays int // maximum age in days for tasks (e.g., 120)
|
||||
MaxClosedAgeDays int // maximum days since closure (e.g., 30)
|
||||
RandSeed int64 // random seed for reproducibility
|
||||
}
|
||||
|
||||
// DefaultLargeConfig returns configuration for 10K issue dataset
|
||||
func DefaultLargeConfig() DataConfig {
|
||||
return DataConfig{
|
||||
TotalIssues: 10000,
|
||||
EpicRatio: 0.1,
|
||||
FeatureRatio: 0.3,
|
||||
OpenRatio: 0.5,
|
||||
CrossLinkRatio: 0.2,
|
||||
MaxEpicAgeDays: 180,
|
||||
MaxFeatureAgeDays: 150,
|
||||
MaxTaskAgeDays: 120,
|
||||
MaxClosedAgeDays: 30,
|
||||
RandSeed: 42,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultXLargeConfig returns configuration for 20K issue dataset
|
||||
func DefaultXLargeConfig() DataConfig {
|
||||
return DataConfig{
|
||||
TotalIssues: 20000,
|
||||
EpicRatio: 0.1,
|
||||
FeatureRatio: 0.3,
|
||||
OpenRatio: 0.5,
|
||||
CrossLinkRatio: 0.2,
|
||||
MaxEpicAgeDays: 180,
|
||||
MaxFeatureAgeDays: 150,
|
||||
MaxTaskAgeDays: 120,
|
||||
MaxClosedAgeDays: 30,
|
||||
RandSeed: 43,
|
||||
}
|
||||
}
|
||||
|
||||
// LargeSQLite creates a 10K issue database with realistic patterns
|
||||
func LargeSQLite(ctx context.Context, store storage.Storage) error {
|
||||
cfg := DefaultLargeConfig()
|
||||
return generateIssuesWithConfig(ctx, store, cfg)
|
||||
}
|
||||
|
||||
// XLargeSQLite creates a 20K issue database with realistic patterns
|
||||
func XLargeSQLite(ctx context.Context, store storage.Storage) error {
|
||||
cfg := DefaultXLargeConfig()
|
||||
return generateIssuesWithConfig(ctx, store, cfg)
|
||||
}
|
||||
|
||||
// LargeFromJSONL creates a 10K issue database by exporting to JSONL and reimporting
|
||||
func LargeFromJSONL(ctx context.Context, store storage.Storage, tempDir string) error {
|
||||
cfg := DefaultLargeConfig()
|
||||
cfg.RandSeed = 44 // different seed for JSONL path
|
||||
return generateFromJSONL(ctx, store, tempDir, cfg)
|
||||
}
|
||||
|
||||
// XLargeFromJSONL creates a 20K issue database by exporting to JSONL and reimporting
|
||||
func XLargeFromJSONL(ctx context.Context, store storage.Storage, tempDir string) error {
|
||||
cfg := DefaultXLargeConfig()
|
||||
cfg.RandSeed = 45 // different seed for JSONL path
|
||||
return generateFromJSONL(ctx, store, tempDir, cfg)
|
||||
}
|
||||
|
||||
// generateIssuesWithConfig creates issues with realistic epic hierarchies and cross-links using provided configuration
|
||||
func generateIssuesWithConfig(ctx context.Context, store storage.Storage, cfg DataConfig) error {
|
||||
rng := rand.New(rand.NewSource(cfg.RandSeed))
|
||||
|
||||
// Calculate breakdown using configuration ratios
|
||||
numEpics := int(float64(cfg.TotalIssues) * cfg.EpicRatio)
|
||||
numFeatures := int(float64(cfg.TotalIssues) * cfg.FeatureRatio)
|
||||
numTasks := cfg.TotalIssues - numEpics - numFeatures
|
||||
|
||||
// Track created issues for cross-linking
|
||||
var allIssues []*types.Issue
|
||||
epicIssues := make([]*types.Issue, 0, numEpics)
|
||||
featureIssues := make([]*types.Issue, 0, numFeatures)
|
||||
taskIssues := make([]*types.Issue, 0, numTasks)
|
||||
|
||||
// Progress tracking
|
||||
createdIssues := 0
|
||||
lastPctLogged := -1
|
||||
|
||||
logProgress := func() {
|
||||
pct := (createdIssues * 100) / cfg.TotalIssues
|
||||
if pct >= lastPctLogged+10 {
|
||||
fmt.Printf(" Progress: %d%% (%d/%d issues created)\n", pct, createdIssues, cfg.TotalIssues)
|
||||
lastPctLogged = pct
|
||||
}
|
||||
}
|
||||
|
||||
// Create epics
|
||||
for i := 0; i < numEpics; i++ {
|
||||
issue := &types.Issue{
|
||||
Title: fmt.Sprintf("%s (Epic %d)", epicTitles[i%len(epicTitles)], i),
|
||||
Description: fmt.Sprintf("Epic for %s", epicTitles[i%len(epicTitles)]),
|
||||
Status: randomStatus(rng, cfg.OpenRatio),
|
||||
Priority: randomPriority(rng),
|
||||
IssueType: types.TypeEpic,
|
||||
Assignee: commonAssignees[rng.Intn(len(commonAssignees))],
|
||||
CreatedAt: randomTime(rng, cfg.MaxEpicAgeDays),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if issue.Status == types.StatusClosed {
|
||||
closedAt := randomTime(rng, cfg.MaxClosedAgeDays)
|
||||
issue.ClosedAt = &closedAt
|
||||
}
|
||||
|
||||
if err := store.CreateIssue(ctx, issue, "fixture"); err != nil {
|
||||
return fmt.Errorf("failed to create epic: %w", err)
|
||||
}
|
||||
|
||||
// Add labels to epics
|
||||
for j := 0; j < rng.Intn(3)+1; j++ {
|
||||
label := commonLabels[rng.Intn(len(commonLabels))]
|
||||
_ = store.AddLabel(ctx, issue.ID, label, "fixture")
|
||||
}
|
||||
|
||||
epicIssues = append(epicIssues, issue)
|
||||
allIssues = append(allIssues, issue)
|
||||
createdIssues++
|
||||
logProgress()
|
||||
}
|
||||
|
||||
// Create features under epics
|
||||
for i := 0; i < numFeatures; i++ {
|
||||
parentEpic := epicIssues[i%len(epicIssues)]
|
||||
|
||||
issue := &types.Issue{
|
||||
Title: fmt.Sprintf("%s (Feature %d)", featureTitles[i%len(featureTitles)], i),
|
||||
Description: fmt.Sprintf("Feature under %s", parentEpic.Title),
|
||||
Status: randomStatus(rng, cfg.OpenRatio),
|
||||
Priority: randomPriority(rng),
|
||||
IssueType: types.TypeFeature,
|
||||
Assignee: commonAssignees[rng.Intn(len(commonAssignees))],
|
||||
CreatedAt: randomTime(rng, cfg.MaxFeatureAgeDays),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if issue.Status == types.StatusClosed {
|
||||
closedAt := randomTime(rng, cfg.MaxClosedAgeDays)
|
||||
issue.ClosedAt = &closedAt
|
||||
}
|
||||
|
||||
if err := store.CreateIssue(ctx, issue, "fixture"); err != nil {
|
||||
return fmt.Errorf("failed to create feature: %w", err)
|
||||
}
|
||||
|
||||
// Add parent-child dependency to epic
|
||||
dep := &types.Dependency{
|
||||
IssueID: issue.ID,
|
||||
DependsOnID: parentEpic.ID,
|
||||
Type: types.DepParentChild,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "fixture",
|
||||
}
|
||||
if err := store.AddDependency(ctx, dep, "fixture"); err != nil {
|
||||
return fmt.Errorf("failed to add feature-epic dependency: %w", err)
|
||||
}
|
||||
|
||||
// Add labels
|
||||
for j := 0; j < rng.Intn(3)+1; j++ {
|
||||
label := commonLabels[rng.Intn(len(commonLabels))]
|
||||
_ = store.AddLabel(ctx, issue.ID, label, "fixture")
|
||||
}
|
||||
|
||||
featureIssues = append(featureIssues, issue)
|
||||
allIssues = append(allIssues, issue)
|
||||
createdIssues++
|
||||
logProgress()
|
||||
}
|
||||
|
||||
// Create tasks under features
|
||||
for i := 0; i < numTasks; i++ {
|
||||
parentFeature := featureIssues[i%len(featureIssues)]
|
||||
|
||||
issue := &types.Issue{
|
||||
Title: fmt.Sprintf("%s (Task %d)", taskTitles[i%len(taskTitles)], i),
|
||||
Description: fmt.Sprintf("Task under %s", parentFeature.Title),
|
||||
Status: randomStatus(rng, cfg.OpenRatio),
|
||||
Priority: randomPriority(rng),
|
||||
IssueType: types.TypeTask,
|
||||
Assignee: commonAssignees[rng.Intn(len(commonAssignees))],
|
||||
CreatedAt: randomTime(rng, cfg.MaxTaskAgeDays),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if issue.Status == types.StatusClosed {
|
||||
closedAt := randomTime(rng, cfg.MaxClosedAgeDays)
|
||||
issue.ClosedAt = &closedAt
|
||||
}
|
||||
|
||||
if err := store.CreateIssue(ctx, issue, "fixture"); err != nil {
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
||||
// Add parent-child dependency to feature
|
||||
dep := &types.Dependency{
|
||||
IssueID: issue.ID,
|
||||
DependsOnID: parentFeature.ID,
|
||||
Type: types.DepParentChild,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "fixture",
|
||||
}
|
||||
if err := store.AddDependency(ctx, dep, "fixture"); err != nil {
|
||||
return fmt.Errorf("failed to add task-feature dependency: %w", err)
|
||||
}
|
||||
|
||||
// Add labels
|
||||
for j := 0; j < rng.Intn(2)+1; j++ {
|
||||
label := commonLabels[rng.Intn(len(commonLabels))]
|
||||
_ = store.AddLabel(ctx, issue.ID, label, "fixture")
|
||||
}
|
||||
|
||||
taskIssues = append(taskIssues, issue)
|
||||
allIssues = append(allIssues, issue)
|
||||
createdIssues++
|
||||
logProgress()
|
||||
}
|
||||
|
||||
fmt.Printf(" Progress: 100%% (%d/%d issues created) - Complete!\n", cfg.TotalIssues, cfg.TotalIssues)
|
||||
|
||||
// Add cross-links between tasks across epics using configured ratio
|
||||
numCrossLinks := int(float64(numTasks) * cfg.CrossLinkRatio)
|
||||
for i := 0; i < numCrossLinks; i++ {
|
||||
fromTask := taskIssues[rng.Intn(len(taskIssues))]
|
||||
toTask := taskIssues[rng.Intn(len(taskIssues))]
|
||||
|
||||
// Avoid self-dependencies
|
||||
if fromTask.ID == toTask.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
dep := &types.Dependency{
|
||||
IssueID: fromTask.ID,
|
||||
DependsOnID: toTask.ID,
|
||||
Type: types.DepBlocks,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedBy: "fixture",
|
||||
}
|
||||
|
||||
// Ignore cycle errors for cross-links (they're expected)
|
||||
_ = store.AddDependency(ctx, dep, "fixture")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateFromJSONL creates issues, exports to JSONL, clears DB, and reimports
|
||||
func generateFromJSONL(ctx context.Context, store storage.Storage, tempDir string, cfg DataConfig) error {
|
||||
// First generate issues normally
|
||||
if err := generateIssuesWithConfig(ctx, store, cfg); err != nil {
|
||||
return fmt.Errorf("failed to generate issues: %w", err)
|
||||
}
|
||||
|
||||
// Export to JSONL
|
||||
jsonlPath := filepath.Join(tempDir, "issues.jsonl")
|
||||
if err := exportToJSONL(ctx, store, jsonlPath); err != nil {
|
||||
return fmt.Errorf("failed to export to JSONL: %w", err)
|
||||
}
|
||||
|
||||
// Clear all issues (we'll reimport them)
|
||||
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get all issues: %w", err)
|
||||
}
|
||||
|
||||
for _, issue := range allIssues {
|
||||
if err := store.DeleteIssue(ctx, issue.ID); err != nil {
|
||||
return fmt.Errorf("failed to delete issue %s: %w", issue.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Import from JSONL
|
||||
if err := importFromJSONL(ctx, store, jsonlPath); err != nil {
|
||||
return fmt.Errorf("failed to import from JSONL: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportToJSONL exports all issues to a JSONL file
|
||||
func exportToJSONL(ctx context.Context, store storage.Storage, path string) error {
|
||||
// Get all issues
|
||||
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query issues: %w", err)
|
||||
}
|
||||
|
||||
// Populate dependencies and labels for each issue
|
||||
allDeps, err := store.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get dependencies: %w", err)
|
||||
}
|
||||
|
||||
for _, issue := range allIssues {
|
||||
issue.Dependencies = allDeps[issue.ID]
|
||||
|
||||
labels, err := store.GetLabels(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get labels for %s: %w", issue.ID, err)
|
||||
}
|
||||
issue.Labels = labels
|
||||
}
|
||||
|
||||
// Write to JSONL file
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create JSONL file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
encoder := json.NewEncoder(f)
|
||||
for _, issue := range allIssues {
|
||||
if err := encoder.Encode(issue); err != nil {
|
||||
return fmt.Errorf("failed to encode issue: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// importFromJSONL imports issues from a JSONL file
|
||||
func importFromJSONL(ctx context.Context, store storage.Storage, path string) error {
|
||||
// Read JSONL file
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read JSONL file: %w", err)
|
||||
}
|
||||
|
||||
// Parse issues
|
||||
var issues []*types.Issue
|
||||
lines := string(data)
|
||||
for i, line := range splitLines(lines) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
return fmt.Errorf("failed to parse issue at line %d: %w", i+1, err)
|
||||
}
|
||||
|
||||
issues = append(issues, &issue)
|
||||
}
|
||||
|
||||
// Import issues directly using storage interface
|
||||
// Step 1: Create all issues first (without dependencies/labels)
|
||||
type savedMetadata struct {
|
||||
deps []*types.Dependency
|
||||
labels []string
|
||||
}
|
||||
metadata := make(map[string]savedMetadata)
|
||||
|
||||
for _, issue := range issues {
|
||||
// Save dependencies and labels for later
|
||||
metadata[issue.ID] = savedMetadata{
|
||||
deps: issue.Dependencies,
|
||||
labels: issue.Labels,
|
||||
}
|
||||
issue.Dependencies = nil
|
||||
issue.Labels = nil
|
||||
|
||||
if err := store.CreateIssue(ctx, issue, "fixture"); err != nil {
|
||||
// Ignore duplicate errors
|
||||
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return fmt.Errorf("failed to create issue %s: %w", issue.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Add all dependencies (now that all issues exist)
|
||||
for issueID, meta := range metadata {
|
||||
for _, dep := range meta.deps {
|
||||
if err := store.AddDependency(ctx, dep, "fixture"); err != nil {
|
||||
// Ignore duplicate and cycle errors
|
||||
if !strings.Contains(err.Error(), "already exists") &&
|
||||
!strings.Contains(err.Error(), "cycle") {
|
||||
return fmt.Errorf("failed to add dependency for %s: %w", issueID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels
|
||||
for _, label := range meta.labels {
|
||||
_ = store.AddLabel(ctx, issueID, label, "fixture")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitLines splits a string by newlines
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
lines = append(lines, s[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// randomStatus returns a random status with given open ratio
|
||||
func randomStatus(rng *rand.Rand, openRatio float64) types.Status {
|
||||
r := rng.Float64()
|
||||
if r < openRatio {
|
||||
// Open statuses: open, in_progress, blocked
|
||||
statuses := []types.Status{types.StatusOpen, types.StatusInProgress, types.StatusBlocked}
|
||||
return statuses[rng.Intn(len(statuses))]
|
||||
}
|
||||
return types.StatusClosed
|
||||
}
|
||||
|
||||
// randomPriority returns a random priority with realistic distribution
|
||||
// P0: 5%, P1: 15%, P2: 50%, P3: 25%, P4: 5%
|
||||
func randomPriority(rng *rand.Rand) int {
|
||||
r := rng.Intn(100)
|
||||
switch {
|
||||
case r < 5:
|
||||
return 0
|
||||
case r < 20:
|
||||
return 1
|
||||
case r < 70:
|
||||
return 2
|
||||
case r < 95:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
// randomTime returns a random time up to maxDaysAgo days in the past
|
||||
func randomTime(rng *rand.Rand, maxDaysAgo int) time.Time {
|
||||
daysAgo := rng.Intn(maxDaysAgo)
|
||||
return time.Now().Add(-time.Duration(daysAgo) * 24 * time.Hour)
|
||||
}
|
||||
128
internal/testutil/fixtures/fixtures_test.go
Normal file
128
internal/testutil/fixtures/fixtures_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package fixtures
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestLargeSQLite(t *testing.T) {
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
store, err := sqlite.New(tmpDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize database with prefix
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd-"); err != nil {
|
||||
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
if err := LargeSQLite(ctx, store); err != nil {
|
||||
t.Fatalf("LargeSQLite failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue count
|
||||
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to search issues: %v", err)
|
||||
}
|
||||
|
||||
if len(allIssues) != 10000 {
|
||||
t.Errorf("Expected 10000 issues, got %d", len(allIssues))
|
||||
}
|
||||
|
||||
// Verify we have epics, features, and tasks
|
||||
var epics, features, tasks int
|
||||
for _, issue := range allIssues {
|
||||
switch issue.IssueType {
|
||||
case types.TypeEpic:
|
||||
epics++
|
||||
case types.TypeFeature:
|
||||
features++
|
||||
case types.TypeTask:
|
||||
tasks++
|
||||
}
|
||||
}
|
||||
|
||||
if epics == 0 || features == 0 || tasks == 0 {
|
||||
t.Errorf("Missing issue types: epics=%d, features=%d, tasks=%d", epics, features, tasks)
|
||||
}
|
||||
|
||||
t.Logf("Created %d epics, %d features, %d tasks", epics, features, tasks)
|
||||
}
|
||||
|
||||
func TestXLargeSQLite(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping XLarge test in short mode")
|
||||
}
|
||||
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
store, err := sqlite.New(tmpDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize database with prefix
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd-"); err != nil {
|
||||
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
if err := XLargeSQLite(ctx, store); err != nil {
|
||||
t.Fatalf("XLargeSQLite failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue count
|
||||
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to search issues: %v", err)
|
||||
}
|
||||
|
||||
if len(allIssues) != 20000 {
|
||||
t.Errorf("Expected 20000 issues, got %d", len(allIssues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargeFromJSONL(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping JSONL test in short mode")
|
||||
}
|
||||
|
||||
tmpDB := t.TempDir() + "/test.db"
|
||||
store, err := sqlite.New(tmpDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize database with prefix
|
||||
if err := store.SetConfig(ctx, "issue_prefix", "bd-"); err != nil {
|
||||
t.Fatalf("Failed to set issue_prefix: %v", err)
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
|
||||
if err := LargeFromJSONL(ctx, store, tempDir); err != nil {
|
||||
t.Fatalf("LargeFromJSONL failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify issue count
|
||||
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to search issues: %v", err)
|
||||
}
|
||||
|
||||
if len(allIssues) != 10000 {
|
||||
t.Errorf("Expected 10000 issues, got %d", len(allIssues))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user