bd-109: Add retry logic and race condition handling for N-way collisions

- Added ExecInTransaction helper for atomic database operations
- Added IsUniqueConstraintError to detect UNIQUE constraint violations
- Wrapped RemapCollisions with retry logic (3 attempts with counter sync)
- Enhanced handleRename to detect race conditions where target ID exists
- Added defensive checks for when old ID has been deleted by another clone

Progress: Improves N-way collision handling, though full solution requires
more work (tracked in bd-108). Tests now reach later convergence rounds
before hitting complex collision scenarios.

Amp-Thread-ID: https://ampcode.com/threads/T-2b850a80-f8bd-4e38-b661-e33d1cfa7281
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-29 10:45:25 -07:00
parent 7ed8d49652
commit ebb425388c
4 changed files with 109 additions and 6 deletions

View File

@@ -285,6 +285,38 @@ func buildIDMap(issues []*types.Issue) map[string]*types.Issue {
// handleRename handles content match with different IDs (rename detected)
func handleRename(ctx context.Context, s *sqlite.SQLiteStorage, existing *types.Issue, incoming *types.Issue) error {
// Check if target ID already exists with the same content (race condition)
// This can happen when multiple clones import the same rename simultaneously
targetIssue, err := s.GetIssue(ctx, incoming.ID)
if err == nil && targetIssue != nil {
// Target ID exists - check if it has the same content
if targetIssue.ComputeContentHash() == incoming.ComputeContentHash() {
// Same content - check if old ID still exists and delete it
existingCheck, checkErr := s.GetIssue(ctx, existing.ID)
if checkErr == nil && existingCheck != nil {
if err := s.DeleteIssue(ctx, existing.ID); err != nil {
return fmt.Errorf("failed to delete old ID %s: %w", existing.ID, err)
}
}
// The rename is already complete in the database
return nil
}
// Different content - this is an unexpected collision
return fmt.Errorf("target ID %s already exists with different content", incoming.ID)
}
// Check if old ID still exists (it might have been deleted by another clone)
existingCheck, checkErr := s.GetIssue(ctx, existing.ID)
if checkErr != nil || existingCheck == nil {
// Old ID doesn't exist - the rename must have been completed by another clone
// Verify that target exists with correct content
targetCheck, targetErr := s.GetIssue(ctx, incoming.ID)
if targetErr == nil && targetCheck != nil && targetCheck.ComputeContentHash() == incoming.ComputeContentHash() {
return nil
}
return fmt.Errorf("old ID %s doesn't exist and target ID %s is not as expected", existing.ID, incoming.ID)
}
// Delete old ID
if err := s.DeleteIssue(ctx, existing.ID); err != nil {
return fmt.Errorf("failed to delete old ID %s: %w", existing.ID, err)
@@ -292,6 +324,15 @@ func handleRename(ctx context.Context, s *sqlite.SQLiteStorage, existing *types.
// Create with new ID
if err := s.CreateIssue(ctx, incoming, "import-rename"); err != nil {
// If UNIQUE constraint error, it's likely another clone created it concurrently
if sqlite.IsUniqueConstraintError(err) {
// Check if target exists with same content
targetIssue, getErr := s.GetIssue(ctx, incoming.ID)
if getErr == nil && targetIssue != nil && targetIssue.ComputeContentHash() == incoming.ComputeContentHash() {
// Same content - rename already complete, this is OK
return nil
}
}
return fmt.Errorf("failed to create renamed issue %s: %w", incoming.ID, err)
}