fix(cleanup): resolve CHECK constraint failure and add tombstone pruning
- Fix bd-tnsq: executeDelete now sets closed_at=NULL when creating tombstones, satisfying the CHECK constraint that requires closed_at IS NULL when status != 'closed' - Fix bd-08ea: cleanup command now also prunes expired tombstones (older than 30 days) after converting closed issues to tombstones - Add regression test for batch deletion of closed issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1618
.beads/issues.jsonl
1618
.beads/issues.jsonl
File diff suppressed because one or more lines are too long
@@ -13,23 +13,26 @@ import (
|
|||||||
|
|
||||||
var cleanupCmd = &cobra.Command{
|
var cleanupCmd = &cobra.Command{
|
||||||
Use: "cleanup",
|
Use: "cleanup",
|
||||||
Short: "Delete closed issues from database to free up space",
|
Short: "Delete closed issues and prune expired tombstones",
|
||||||
Long: `Delete closed issues from the database to reduce database size.
|
Long: `Delete closed issues and prune expired tombstones to reduce database size.
|
||||||
|
|
||||||
|
This command:
|
||||||
|
1. Converts closed issues to tombstones (soft delete)
|
||||||
|
2. Prunes expired tombstones (older than 30 days) from issues.jsonl
|
||||||
|
|
||||||
This command permanently removes closed issues from beads.db and issues.jsonl.
|
|
||||||
It does NOT remove temporary files - use 'bd clean' for that.
|
It does NOT remove temporary files - use 'bd clean' for that.
|
||||||
|
|
||||||
By default, deletes ALL closed issues. Use --older-than to only delete
|
By default, deletes ALL closed issues. Use --older-than to only delete
|
||||||
issues closed before a certain date.
|
issues closed before a certain date.
|
||||||
|
|
||||||
EXAMPLES:
|
EXAMPLES:
|
||||||
Delete all closed issues:
|
Delete all closed issues and prune tombstones:
|
||||||
bd cleanup --force
|
bd cleanup --force
|
||||||
|
|
||||||
Delete issues closed more than 30 days ago:
|
Delete issues closed more than 30 days ago:
|
||||||
bd cleanup --older-than 30 --force
|
bd cleanup --older-than 30 --force
|
||||||
|
|
||||||
Preview what would be deleted:
|
Preview what would be deleted/pruned:
|
||||||
bd cleanup --dry-run
|
bd cleanup --dry-run
|
||||||
bd cleanup --older-than 90 --dry-run
|
bd cleanup --older-than 90 --dry-run
|
||||||
|
|
||||||
@@ -40,7 +43,8 @@ SAFETY:
|
|||||||
- Use --json for programmatic output
|
- Use --json for programmatic output
|
||||||
|
|
||||||
SEE ALSO:
|
SEE ALSO:
|
||||||
bd clean Remove temporary git merge artifacts`,
|
bd clean Remove temporary git merge artifacts
|
||||||
|
bd compact Run compaction on issues`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
@@ -128,6 +132,37 @@ SEE ALSO:
|
|||||||
|
|
||||||
// Use the existing batch deletion logic
|
// Use the existing batch deletion logic
|
||||||
deleteBatch(cmd, issueIDs, force, dryRun, cascade, jsonOutput, "cleanup")
|
deleteBatch(cmd, issueIDs, force, dryRun, cascade, jsonOutput, "cleanup")
|
||||||
|
|
||||||
|
// Also prune expired tombstones (bd-08ea)
|
||||||
|
// This runs after closed issues are converted to tombstones, cleaning up old ones
|
||||||
|
if dryRun {
|
||||||
|
// Preview what tombstones would be pruned
|
||||||
|
tombstoneResult, err := previewPruneTombstones()
|
||||||
|
if err != nil {
|
||||||
|
if !jsonOutput {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to check tombstones: %v\n", err)
|
||||||
|
}
|
||||||
|
} else if tombstoneResult != nil && tombstoneResult.PrunedCount > 0 {
|
||||||
|
if !jsonOutput {
|
||||||
|
fmt.Printf("\nExpired tombstones that would be pruned: %d (older than %d days)\n",
|
||||||
|
tombstoneResult.PrunedCount, tombstoneResult.TTLDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if force {
|
||||||
|
// Actually prune expired tombstones
|
||||||
|
tombstoneResult, err := pruneExpiredTombstones()
|
||||||
|
if err != nil {
|
||||||
|
if !jsonOutput {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", err)
|
||||||
|
}
|
||||||
|
} else if tombstoneResult != nil && tombstoneResult.PrunedCount > 0 {
|
||||||
|
if !jsonOutput {
|
||||||
|
green := color.New(color.FgGreen).SprintFunc()
|
||||||
|
fmt.Printf("\n%s Pruned %d expired tombstone(s) (older than %d days)\n",
|
||||||
|
green("✓"), tombstoneResult.PrunedCount, tombstoneResult.TTLDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1076,6 +1076,58 @@ func pruneExpiredTombstones() (*TombstonePruneResult, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// previewPruneTombstones checks what tombstones would be pruned without modifying files.
|
||||||
|
// Used for dry-run mode in cleanup command (bd-08ea).
|
||||||
|
func previewPruneTombstones() (*TombstonePruneResult, error) {
|
||||||
|
beadsDir := filepath.Dir(dbPath)
|
||||||
|
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
// Check if issues.jsonl exists
|
||||||
|
if _, err := os.Stat(issuesPath); os.IsNotExist(err) {
|
||||||
|
return &TombstonePruneResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all issues
|
||||||
|
// nolint:gosec // G304: issuesPath is controlled from beadsDir
|
||||||
|
file, err := os.Open(issuesPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open issues.jsonl: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var allIssues []*types.Issue
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
if err.Error() == "EOF" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Skip corrupt lines
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allIssues = append(allIssues, &issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine TTL
|
||||||
|
ttl := types.DefaultTombstoneTTL
|
||||||
|
ttlDays := int(ttl.Hours() / 24)
|
||||||
|
|
||||||
|
// Count expired tombstones
|
||||||
|
var prunedIDs []string
|
||||||
|
for _, issue := range allIssues {
|
||||||
|
if issue.IsExpired(ttl) {
|
||||||
|
prunedIDs = append(prunedIDs, issue.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TombstonePruneResult{
|
||||||
|
PrunedCount: len(prunedIDs),
|
||||||
|
PrunedIDs: prunedIDs,
|
||||||
|
TTLDays: ttlDays,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting")
|
compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting")
|
||||||
compactCmd.Flags().IntVar(&compactTier, "tier", 1, "Compaction tier (1 or 2)")
|
compactCmd.Flags().IntVar(&compactTier, "tier", 1, "Compaction tier (1 or 2)")
|
||||||
|
|||||||
@@ -1202,12 +1202,15 @@ func (s *SQLiteStorage) executeDelete(ctx context.Context, tx *sql.Tx, inClause
|
|||||||
_ = rows.Close()
|
_ = rows.Close()
|
||||||
|
|
||||||
// 3. Convert issues to tombstones (only for issues that exist)
|
// 3. Convert issues to tombstones (only for issues that exist)
|
||||||
|
// Note: closed_at must be set to NULL because of CHECK constraint:
|
||||||
|
// (status = 'closed') = (closed_at IS NOT NULL)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
deletedCount := 0
|
deletedCount := 0
|
||||||
for id, originalType := range issueTypes {
|
for id, originalType := range issueTypes {
|
||||||
execResult, err := tx.ExecContext(ctx, `
|
execResult, err := tx.ExecContext(ctx, `
|
||||||
UPDATE issues
|
UPDATE issues
|
||||||
SET status = ?,
|
SET status = ?,
|
||||||
|
closed_at = NULL,
|
||||||
deleted_at = ?,
|
deleted_at = ?,
|
||||||
deleted_by = ?,
|
deleted_by = ?,
|
||||||
delete_reason = ?,
|
delete_reason = ?,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sqlite
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
@@ -299,6 +300,67 @@ func TestDeleteIssuesCreatesTombstones(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("batch deletion of closed issues creates tombstones (bd-tnsq)", func(t *testing.T) {
|
||||||
|
// Regression test: batch deletion of closed issues was failing with
|
||||||
|
// CHECK constraint: (status = 'closed') = (closed_at IS NOT NULL)
|
||||||
|
// because closed_at wasn't being set to NULL when creating tombstones
|
||||||
|
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
closedAt := now.Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
// Create closed issues (with closed_at set)
|
||||||
|
issue1 := &types.Issue{
|
||||||
|
ID: "bd-closed-10",
|
||||||
|
Title: "Closed Issue 1",
|
||||||
|
Status: types.StatusClosed,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeBug,
|
||||||
|
ClosedAt: &closedAt,
|
||||||
|
}
|
||||||
|
issue2 := &types.Issue{
|
||||||
|
ID: "bd-closed-11",
|
||||||
|
Title: "Closed Issue 2",
|
||||||
|
Status: types.StatusClosed,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
ClosedAt: &closedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create closed issue1: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to create closed issue2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch delete closed issues - this was failing before the fix
|
||||||
|
result, err := store.DeleteIssues(ctx, []string{"bd-closed-10", "bd-closed-11"}, false, true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteIssues on closed issues failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.DeletedCount != 2 {
|
||||||
|
t.Errorf("Expected 2 deletions, got %d", result.DeletedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tombstones have closed_at = NULL (required by CHECK constraint)
|
||||||
|
tombstone1, _ := store.GetIssue(ctx, "bd-closed-10")
|
||||||
|
if tombstone1 == nil || tombstone1.Status != types.StatusTombstone {
|
||||||
|
t.Error("bd-closed-10 should be tombstone")
|
||||||
|
}
|
||||||
|
if tombstone1.ClosedAt != nil {
|
||||||
|
t.Error("bd-closed-10 tombstone should have closed_at = NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
tombstone2, _ := store.GetIssue(ctx, "bd-closed-11")
|
||||||
|
if tombstone2 == nil || tombstone2.Status != types.StatusTombstone {
|
||||||
|
t.Error("bd-closed-11 should be tombstone")
|
||||||
|
}
|
||||||
|
if tombstone2.ClosedAt != nil {
|
||||||
|
t.Error("bd-closed-11 tombstone should have closed_at = NULL")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("cascade deletion creates tombstones", func(t *testing.T) {
|
t.Run("cascade deletion creates tombstones", func(t *testing.T) {
|
||||||
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
|
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user