Complete implementation of signal-aware context propagation for graceful cancellation across all commands and storage operations. Key changes: 1. Signal-aware contexts (bd-rtp): - Added rootCtx/rootCancel in main.go using signal.NotifyContext() - Set up in PersistentPreRun, cancelled in PersistentPostRun - Daemon uses same pattern in runDaemonLoop() - Handles SIGINT/SIGTERM for graceful shutdown 2. Context propagation (bd-yb8): - All commands now use rootCtx instead of context.Background() - sqlite.New() receives context for cancellable operations - Database operations respect context cancellation - Storage layer propagates context through all queries 3. Cancellation tests (bd-2o2): - Added import_cancellation_test.go with comprehensive tests - Added export cancellation test in export_test.go - Tests verify database integrity after cancellation - All cancellation tests passing Fixes applied during review: - Fixed rootCtx lifecycle (removed premature defer from PersistentPreRun) - Fixed test context contamination (reset rootCtx in test cleanup) - Fixed export tests missing context setup Impact: - Pressing Ctrl+C during import/export now cancels gracefully - No database corruption or hanging transactions - Clean shutdown of all operations Tested: - go build ./cmd/bd ✓ - go test ./cmd/bd -run TestImportCancellation ✓ - go test ./cmd/bd -run TestExportCommand ✓ - Manual Ctrl+C testing verified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
394 lines
10 KiB
Go
394 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestRepairDeps_NoOrphans(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Initialize database
|
|
store.SetConfig(ctx, "issue_prefix", "test-")
|
|
|
|
// Create two issues with valid dependency
|
|
i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"}
|
|
store.CreateIssue(ctx, i1, "test")
|
|
i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"}
|
|
store.CreateIssue(ctx, i2, "test")
|
|
store.AddDependency(ctx, &types.Dependency{
|
|
IssueID: i2.ID,
|
|
DependsOnID: i1.ID,
|
|
Type: types.DepBlocks,
|
|
}, "test")
|
|
|
|
// Get all dependency records
|
|
allDeps, err := store.GetAllDependencyRecords(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Get all issues
|
|
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Build valid ID set
|
|
validIDs := make(map[string]bool)
|
|
for _, issue := range issues {
|
|
validIDs[issue.ID] = true
|
|
}
|
|
|
|
// Find orphans
|
|
orphanCount := 0
|
|
for issueID, deps := range allDeps {
|
|
if !validIDs[issueID] {
|
|
continue
|
|
}
|
|
for _, dep := range deps {
|
|
if !validIDs[dep.DependsOnID] {
|
|
orphanCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if orphanCount != 0 {
|
|
t.Errorf("Expected 0 orphans, got %d", orphanCount)
|
|
}
|
|
}
|
|
|
|
func TestRepairDeps_FindOrphans(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Initialize database
|
|
store.SetConfig(ctx, "issue_prefix", "test-")
|
|
|
|
// Create two issues
|
|
i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"}
|
|
if err := store.CreateIssue(ctx, i1, "test"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
t.Logf("Created i1: %s", i1.ID)
|
|
|
|
i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"}
|
|
if err := store.CreateIssue(ctx, i2, "test"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
t.Logf("Created i2: %s", i2.ID)
|
|
|
|
// Add dependency
|
|
err = store.AddDependency(ctx, &types.Dependency{
|
|
IssueID: i2.ID,
|
|
DependsOnID: i1.ID,
|
|
Type: types.DepBlocks,
|
|
}, "test")
|
|
if err != nil {
|
|
t.Fatalf("AddDependency failed: %v", err)
|
|
}
|
|
|
|
// Manually create orphaned dependency by directly inserting invalid reference
|
|
// This simulates corruption or import errors
|
|
db := store.UnderlyingDB()
|
|
_, err = db.ExecContext(ctx, "PRAGMA foreign_keys = OFF")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Insert a dependency pointing to a non-existent issue
|
|
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
|
VALUES (?, 'nonexistent-123', 'blocks', datetime('now'), 'test')`, i2.ID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert orphaned dependency: %v", err)
|
|
}
|
|
_, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify the orphan was actually inserted
|
|
var count int
|
|
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dependencies WHERE depends_on_id = 'nonexistent-123'").Scan(&count)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("Orphan dependency not inserted, count=%d", count)
|
|
}
|
|
|
|
// Get all dependency records
|
|
allDeps, err := store.GetAllDependencyRecords(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Logf("Got %d issues with dependencies", len(allDeps))
|
|
for issueID, deps := range allDeps {
|
|
t.Logf("Issue %s has %d dependencies", issueID, len(deps))
|
|
for _, dep := range deps {
|
|
t.Logf(" -> %s (%s)", dep.DependsOnID, dep.Type)
|
|
}
|
|
}
|
|
|
|
// Get all issues
|
|
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Build valid ID set
|
|
validIDs := make(map[string]bool)
|
|
for _, issue := range issues {
|
|
validIDs[issue.ID] = true
|
|
}
|
|
t.Logf("Valid issue IDs: %v", validIDs)
|
|
|
|
// Find orphans
|
|
orphanCount := 0
|
|
for issueID, deps := range allDeps {
|
|
if !validIDs[issueID] {
|
|
t.Logf("Skipping %s - issue itself doesn't exist", issueID)
|
|
continue
|
|
}
|
|
for _, dep := range deps {
|
|
if !validIDs[dep.DependsOnID] {
|
|
t.Logf("Found orphan: %s -> %s", dep.IssueID, dep.DependsOnID)
|
|
orphanCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if orphanCount != 1 {
|
|
t.Errorf("Expected 1 orphan, got %d", orphanCount)
|
|
}
|
|
}
|
|
|
|
func TestRepairDeps_FixOrphans(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Initialize database
|
|
store.SetConfig(ctx, "issue_prefix", "test-")
|
|
|
|
// Create three issues
|
|
i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"}
|
|
store.CreateIssue(ctx, i1, "test")
|
|
i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"}
|
|
store.CreateIssue(ctx, i2, "test")
|
|
i3 := &types.Issue{Title: "Issue 3", Priority: 1, Status: "open", IssueType: "task"}
|
|
store.CreateIssue(ctx, i3, "test")
|
|
|
|
// Add dependencies
|
|
store.AddDependency(ctx, &types.Dependency{
|
|
IssueID: i2.ID,
|
|
DependsOnID: i1.ID,
|
|
Type: types.DepBlocks,
|
|
}, "test")
|
|
store.AddDependency(ctx, &types.Dependency{
|
|
IssueID: i3.ID,
|
|
DependsOnID: i1.ID,
|
|
Type: types.DepBlocks,
|
|
}, "test")
|
|
|
|
// Manually create orphaned dependencies by inserting invalid references
|
|
db := store.UnderlyingDB()
|
|
db.Exec("PRAGMA foreign_keys = OFF")
|
|
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
|
VALUES (?, 'nonexistent-123', 'blocks', datetime('now'), 'test')`, i2.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
|
VALUES (?, 'nonexistent-456', 'blocks', datetime('now'), 'test')`, i3.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Exec("PRAGMA foreign_keys = ON")
|
|
|
|
// Find and fix orphans
|
|
allDeps, _ := store.GetAllDependencyRecords(ctx)
|
|
issues, _ := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
|
|
validIDs := make(map[string]bool)
|
|
for _, issue := range issues {
|
|
validIDs[issue.ID] = true
|
|
}
|
|
|
|
type orphan struct {
|
|
issueID string
|
|
dependsOnID string
|
|
}
|
|
var orphans []orphan
|
|
|
|
for issueID, deps := range allDeps {
|
|
if !validIDs[issueID] {
|
|
continue
|
|
}
|
|
for _, dep := range deps {
|
|
if !validIDs[dep.DependsOnID] {
|
|
orphans = append(orphans, orphan{
|
|
issueID: dep.IssueID,
|
|
dependsOnID: dep.DependsOnID,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(orphans) != 2 {
|
|
t.Fatalf("Expected 2 orphans before fix, got %d", len(orphans))
|
|
}
|
|
|
|
// Fix orphans using direct SQL (like the command does)
|
|
for _, o := range orphans {
|
|
_, delErr := db.ExecContext(ctx, "DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?",
|
|
o.issueID, o.dependsOnID)
|
|
if delErr != nil {
|
|
t.Errorf("Failed to remove orphan: %v", delErr)
|
|
}
|
|
}
|
|
|
|
// Verify orphans removed
|
|
allDeps, _ = store.GetAllDependencyRecords(ctx)
|
|
orphanCount := 0
|
|
for issueID, deps := range allDeps {
|
|
if !validIDs[issueID] {
|
|
continue
|
|
}
|
|
for _, dep := range deps {
|
|
if !validIDs[dep.DependsOnID] {
|
|
orphanCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
if orphanCount != 0 {
|
|
t.Errorf("Expected 0 orphans after fix, got %d", orphanCount)
|
|
}
|
|
}
|
|
|
|
func TestRepairDeps_MultipleTypes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, ".beads", "beads.db")
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Initialize database
|
|
store.SetConfig(ctx, "issue_prefix", "test-")
|
|
|
|
// Create issues
|
|
i1 := &types.Issue{Title: "Issue 1", Priority: 1, Status: "open", IssueType: "task"}
|
|
store.CreateIssue(ctx, i1, "test")
|
|
i2 := &types.Issue{Title: "Issue 2", Priority: 1, Status: "open", IssueType: "task"}
|
|
store.CreateIssue(ctx, i2, "test")
|
|
i3 := &types.Issue{Title: "Issue 3", Priority: 1, Status: "open", IssueType: "task"}
|
|
store.CreateIssue(ctx, i3, "test")
|
|
|
|
// Add different dependency types
|
|
store.AddDependency(ctx, &types.Dependency{
|
|
IssueID: i2.ID,
|
|
DependsOnID: i1.ID,
|
|
Type: types.DepBlocks,
|
|
}, "test")
|
|
store.AddDependency(ctx, &types.Dependency{
|
|
IssueID: i3.ID,
|
|
DependsOnID: i1.ID,
|
|
Type: types.DepRelated,
|
|
}, "test")
|
|
|
|
// Manually create orphaned dependencies with different types
|
|
db := store.UnderlyingDB()
|
|
db.Exec("PRAGMA foreign_keys = OFF")
|
|
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
|
VALUES (?, 'nonexistent-blocks', 'blocks', datetime('now'), 'test')`, i2.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = db.ExecContext(ctx, `INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
|
VALUES (?, 'nonexistent-related', 'related', datetime('now'), 'test')`, i3.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Exec("PRAGMA foreign_keys = ON")
|
|
|
|
// Find orphans
|
|
allDeps, _ := store.GetAllDependencyRecords(ctx)
|
|
issues, _ := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
|
|
validIDs := make(map[string]bool)
|
|
for _, issue := range issues {
|
|
validIDs[issue.ID] = true
|
|
}
|
|
|
|
orphanCount := 0
|
|
depTypes := make(map[types.DependencyType]int)
|
|
for issueID, deps := range allDeps {
|
|
if !validIDs[issueID] {
|
|
continue
|
|
}
|
|
for _, dep := range deps {
|
|
if !validIDs[dep.DependsOnID] {
|
|
orphanCount++
|
|
depTypes[dep.Type]++
|
|
}
|
|
}
|
|
}
|
|
|
|
if orphanCount != 2 {
|
|
t.Errorf("Expected 2 orphans, got %d", orphanCount)
|
|
}
|
|
if depTypes[types.DepBlocks] != 1 {
|
|
t.Errorf("Expected 1 blocks orphan, got %d", depTypes[types.DepBlocks])
|
|
}
|
|
if depTypes[types.DepRelated] != 1 {
|
|
t.Errorf("Expected 1 related orphan, got %d", depTypes[types.DepRelated])
|
|
}
|
|
}
|