fix(storage): use strict INSERT for batch issue creation (GH#956)
Add insertIssuesStrict function that uses plain INSERT instead of INSERT OR IGNORE. Update bulkInsertIssues and transactional CreateIssues to use the strict variant. This fixes a race condition where INSERT OR IGNORE could silently skip duplicate insertions, but the code would still attempt to record events for those "inserted" issues, causing FOREIGN KEY constraint failures. The strict INSERT will now fail explicitly if a duplicate is encountered, which should never happen since checkForExistingIDs runs first within the same transaction. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -204,3 +204,68 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertIssuesStrict bulk inserts multiple issues using plain INSERT (no OR IGNORE).
|
||||
// This is used for fresh batch issue creation (CreateIssues) where duplicates indicate a bug.
|
||||
// For imports where duplicates are expected, use insertIssues instead.
|
||||
// GH#956: Using plain INSERT prevents FK constraint errors from silent INSERT OR IGNORE failures.
|
||||
func insertIssuesStrict(ctx context.Context, conn *sql.Conn, issues []*types.Issue) error {
|
||||
stmt, err := conn.PrepareContext(ctx, `
|
||||
INSERT INTO issues (
|
||||
id, content_hash, title, description, design, acceptance_criteria, notes,
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, created_by, owner, updated_at, closed_at, external_ref, source_repo, close_reason,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template, crystallizes,
|
||||
await_type, await_id, timeout_ns, waiters, mol_type,
|
||||
event_kind, actor, target, payload,
|
||||
due_at, defer_until
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
}
|
||||
defer func() { _ = stmt.Close() }()
|
||||
|
||||
for _, issue := range issues {
|
||||
sourceRepo := issue.SourceRepo
|
||||
if sourceRepo == "" {
|
||||
sourceRepo = "." // Default to primary repo
|
||||
}
|
||||
|
||||
wisp := 0
|
||||
if issue.Ephemeral {
|
||||
wisp = 1
|
||||
}
|
||||
pinned := 0
|
||||
if issue.Pinned {
|
||||
pinned = 1
|
||||
}
|
||||
isTemplate := 0
|
||||
if issue.IsTemplate {
|
||||
isTemplate = 1
|
||||
}
|
||||
crystallizes := 0
|
||||
if issue.Crystallizes {
|
||||
crystallizes = 1
|
||||
}
|
||||
|
||||
_, err = stmt.ExecContext(ctx,
|
||||
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
|
||||
issue.AcceptanceCriteria, issue.Notes, issue.Status,
|
||||
issue.Priority, issue.IssueType, issue.Assignee,
|
||||
issue.EstimatedMinutes, issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt,
|
||||
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
|
||||
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
|
||||
issue.Sender, wisp, pinned, isTemplate, crystallizes,
|
||||
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
|
||||
string(issue.MolType),
|
||||
issue.EventKind, issue.Actor, issue.Target, issue.Payload,
|
||||
issue.DueAt, issue.DeferUntil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user