Skip to content

Commit 6e4e6f2

Browse files
committed
fix(items): centralize creation collision retries (WI-955)
1 parent 21e4350 commit 6e4e6f2

9 files changed

Lines changed: 214 additions & 219 deletions

internal/repository/fracindex.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,7 @@ func isFracIndexRetryableTransactionError(err error) bool {
7979

8080
// IsSerializationAbort reports PostgreSQL serialization failures and
8181
// deadlocks — the cases where retrying the whole transaction on a fresh
82-
// snapshot is the documented recovery. Exported for service-level clone
83-
// transactions that need the same retry classification.
82+
// snapshot is the documented recovery.
8483
func IsSerializationAbort(err error) bool {
8584
return isFracIndexRetryableTransactionError(err)
8685
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package repository
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"windshift/internal/database"
8+
"windshift/internal/models"
9+
)
10+
11+
// IsItemCreateRetryable reports whether item creation should restart on a
12+
// fresh transaction.
13+
func IsItemCreateRetryable(err error) bool {
14+
return IsFracIndexUniqueViolation(err) ||
15+
IsWorkspaceItemNumberUniqueViolation(err) ||
16+
IsSerializationAbort(err)
17+
}
18+
19+
// WithItemCreateTransaction runs one complete item creation transaction and
20+
// retries conflicts that require a fresh snapshot. The callback must allocate
21+
// ranks and workspace item numbers inside the supplied transaction.
22+
func WithItemCreateTransaction(
23+
ctx context.Context,
24+
db database.Database,
25+
create func(tx database.Tx) (int, error),
26+
) (int, error) {
27+
if ctx == nil {
28+
return 0, fmt.Errorf("item creation requires a context")
29+
}
30+
if db == nil {
31+
return 0, fmt.Errorf("item creation requires a database")
32+
}
33+
if create == nil {
34+
return 0, fmt.Errorf("item creation requires a transaction callback")
35+
}
36+
37+
var lastErr error
38+
for range FracIndexMaxRetries {
39+
if err := ctx.Err(); err != nil {
40+
return 0, err
41+
}
42+
43+
tx, err := db.BeginTx(ctx, nil)
44+
if err != nil {
45+
return 0, fmt.Errorf("begin item creation transaction: %w", err)
46+
}
47+
48+
itemID, err := create(tx)
49+
if err == nil {
50+
if commitErr := tx.Commit(); commitErr != nil {
51+
err = fmt.Errorf("commit item creation transaction: %w", commitErr)
52+
}
53+
}
54+
if err == nil {
55+
return itemID, nil
56+
}
57+
_ = tx.Rollback()
58+
59+
if !IsItemCreateRetryable(err) {
60+
return 0, err
61+
}
62+
lastErr = err
63+
}
64+
65+
return 0, fmt.Errorf("item creation failed after %d attempts: %w", FracIndexMaxRetries, lastErr)
66+
}
67+
68+
// CreateWithRetry appends an item and any source-specific records in one
69+
// retryable transaction.
70+
func (r *ItemRepository) CreateWithRetry(
71+
ctx context.Context,
72+
item *models.Item,
73+
afterCreate func(tx database.Tx, itemID int) error,
74+
) (int, error) {
75+
if item == nil {
76+
return 0, fmt.Errorf("item creation requires an item")
77+
}
78+
79+
itemID, err := WithItemCreateTransaction(ctx, r.db, func(tx database.Tx) (int, error) {
80+
attempt := *item
81+
nextNumber, err := r.GetNextWorkspaceItemNumber(tx, attempt.WorkspaceID)
82+
if err != nil {
83+
return 0, fmt.Errorf("allocate workspace item number: %w", err)
84+
}
85+
attempt.WorkspaceItemNumber = nextNumber
86+
attempt.FracIndex = nil
87+
88+
itemID, err := r.Create(tx, &attempt)
89+
if err != nil {
90+
return 0, err
91+
}
92+
if afterCreate != nil {
93+
if err := afterCreate(tx, itemID); err != nil {
94+
return 0, err
95+
}
96+
}
97+
return itemID, nil
98+
})
99+
if err != nil {
100+
return 0, err
101+
}
102+
InvalidateItemListCountCache(r.db)
103+
return itemID, nil
104+
}

internal/repository/item_repository.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,9 @@ func (r *ItemRepository) GetNextWorkspaceItemNumber(tx database.Tx, workspaceID
755755
return int(maxNumber + 1), nil
756756
}
757757

758+
// Create inserts an item in a caller-owned transaction. Production creation
759+
// flows should use CreateWithRetry so rank and item-number conflicts restart
760+
// the full transaction.
758761
func (r *ItemRepository) Create(tx database.Tx, item *models.Item) (int, error) {
759762
if err := acquireGlobalRankMutationLock(tx, r.db.GetDriverName()); err != nil {
760763
return 0, err

internal/repository/workspace_template_repository.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ func (r *WorkspaceTemplateRepository) InsertClonedItemTx(ctx context.Context, tx
317317
insert.CreatorID, insert.CreatedAt, insert.CreatedAt, insert.CreatedAt).Scan(&id)
318318
if err != nil {
319319
if database.IsUniqueConstraintError(err) {
320-
return 0, ErrDuplicateEntry
320+
return 0, fmt.Errorf("%w: %w", ErrDuplicateEntry, err)
321321
}
322322
return 0, fmt.Errorf("insert cloned item: %w", err)
323323
}

internal/scheduler/recurrence_scheduler.go

Lines changed: 23 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package scheduler
22

33
import (
4+
"context"
45
"fmt"
56
"log/slog"
67
"sync"
@@ -265,24 +266,6 @@ func (rs *RecurrenceScheduler) generateInstancesForRule(rule *models.RecurrenceR
265266

266267
// createInstance creates a single recurring task instance
267268
func (rs *RecurrenceScheduler) createInstance(rule *models.RecurrenceRule, template *models.Item, scheduledDate time.Time) error {
268-
tx, err := rs.db.Begin()
269-
if err != nil {
270-
return fmt.Errorf("failed to begin transaction: %w", err)
271-
}
272-
defer func() { _ = tx.Rollback() }()
273-
274-
// Get next workspace item number
275-
nextNum, err := rs.itemRepo.GetNextWorkspaceItemNumber(tx, template.WorkspaceID)
276-
if err != nil {
277-
return fmt.Errorf("failed to get next item number: %w", err)
278-
}
279-
280-
// Get next sequence number for this rule
281-
seqNum, err := rs.recurrenceRepo.GetNextSequenceNumber(tx, rule.ID)
282-
if err != nil {
283-
return fmt.Errorf("failed to get sequence number: %w", err)
284-
}
285-
286269
// Determine status. The user can pin one explicitly; otherwise resolve via the
287270
// workspace's configuration set → workflow → initial transition (the same path
288271
// used by handlers/portal.go and handlers/forms.go). The previous hard-coded
@@ -306,14 +289,13 @@ func (rs *RecurrenceScheduler) createInstance(rule *models.RecurrenceRule, templ
306289
// goes through ItemRepository so the items-table write lives in the
307290
// repository layer (same path scm/issue_sync.go uses), not in the scheduler.
308291
item := &models.Item{
309-
WorkspaceID: template.WorkspaceID,
310-
WorkspaceItemNumber: nextNum,
311-
ItemTypeID: template.ItemTypeID,
312-
Title: template.Title,
313-
StatusID: &statusID,
314-
DueDate: &scheduledDate,
315-
IsTask: template.IsTask,
316-
ParentID: template.ParentID,
292+
WorkspaceID: template.WorkspaceID,
293+
ItemTypeID: template.ItemTypeID,
294+
Title: template.Title,
295+
StatusID: &statusID,
296+
DueDate: &scheduledDate,
297+
IsTask: template.IsTask,
298+
ParentID: template.ParentID,
317299
}
318300
if rule.CopyDescription {
319301
item.Description = template.Description
@@ -328,24 +310,23 @@ func (rs *RecurrenceScheduler) createInstance(rule *models.RecurrenceRule, templ
328310
item.CustomFieldValues = template.CustomFieldValues
329311
}
330312

331-
itemID, err := rs.itemRepo.Create(tx, item)
332-
if err != nil {
333-
return fmt.Errorf("failed to create item: %w", err)
334-
}
335-
336-
// Create the instance record
337-
err = rs.recurrenceRepo.CreateInstance(tx, &models.RecurrenceInstance{
338-
RecurrenceRuleID: rule.ID,
339-
InstanceItemID: itemID,
340-
ScheduledDate: scheduledDate,
341-
SequenceNumber: seqNum,
313+
itemID, err := rs.itemRepo.CreateWithRetry(context.Background(), item, func(tx database.Tx, itemID int) error {
314+
seqNum, err := rs.recurrenceRepo.GetNextSequenceNumber(tx, rule.ID)
315+
if err != nil {
316+
return fmt.Errorf("get next recurrence sequence number: %w", err)
317+
}
318+
if err := rs.recurrenceRepo.CreateInstance(tx, &models.RecurrenceInstance{
319+
RecurrenceRuleID: rule.ID,
320+
InstanceItemID: itemID,
321+
ScheduledDate: scheduledDate,
322+
SequenceNumber: seqNum,
323+
}); err != nil {
324+
return fmt.Errorf("create recurrence instance: %w", err)
325+
}
326+
return nil
342327
})
343328
if err != nil {
344-
return fmt.Errorf("failed to create instance record: %w", err)
345-
}
346-
347-
if err := tx.Commit(); err != nil {
348-
return err
329+
return fmt.Errorf("create recurring item: %w", err)
349330
}
350331

351332
// Live-update publish (WI-483): the recurrence instance committed. Announce

internal/scm/issue_sync.go

Lines changed: 31 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -283,61 +283,46 @@ func (s *IssueSyncService) createItemFromIssue(ctx context.Context, config *mode
283283

284284
milestoneID := s.resolveMilestoneID(config, issue)
285285

286-
tx, err := s.db.BeginTx(ctx, nil)
287-
if err != nil {
288-
return fmt.Errorf("begin tx: %w", err)
289-
}
290-
defer func() { _ = tx.Rollback() }()
291-
292-
nextNum, err := s.itemRepo.GetNextWorkspaceItemNumber(tx, config.WorkspaceID)
293-
if err != nil {
294-
return fmt.Errorf("get next item number: %w", err)
295-
}
296-
297286
newItem := &models.Item{
298-
WorkspaceID: config.WorkspaceID,
299-
WorkspaceItemNumber: nextNum,
300-
ItemTypeID: config.DefaultItemTypeID,
301-
Title: issue.Title,
302-
Description: issue.Body,
303-
StatusID: statusID,
304-
PriorityID: config.DefaultPriorityID,
305-
AssigneeID: assigneeID,
306-
}
307-
itemID, err := s.itemRepo.Create(tx, newItem)
308-
if err != nil {
309-
return fmt.Errorf("create item: %w", err)
287+
WorkspaceID: config.WorkspaceID,
288+
ItemTypeID: config.DefaultItemTypeID,
289+
Title: issue.Title,
290+
Description: issue.Body,
291+
StatusID: statusID,
292+
PriorityID: config.DefaultPriorityID,
293+
AssigneeID: assigneeID,
310294
}
311295

312296
milestoneIDs := []int{}
313297
if milestoneID != nil {
314298
milestoneIDs = append(milestoneIDs, *milestoneID)
315299
}
316-
if err := repository.NewMilestoneAttachRepository(s.db).ReplaceItemMilestonesTx(ctx, tx, itemID, milestoneIDs); err != nil {
317-
return err
318-
}
319-
320-
now := time.Now()
321-
_, err = tx.Exec(`
322-
INSERT INTO issue_sync_items (
323-
issue_sync_config_id, item_id, github_issue_number, github_issue_id,
324-
github_issue_url, last_synced_at, last_github_updated_at, created_at, updated_at
325-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
326-
`,
327-
config.ID, itemID, issue.Number, issue.ID,
328-
issue.URL, now, issue.UpdatedAt, now, now,
329-
)
330-
if err != nil {
331-
return fmt.Errorf("insert sync item: %w", err)
332-
}
300+
itemID, err := s.itemRepo.CreateWithRetry(ctx, newItem, func(tx database.Tx, itemID int) error {
301+
if err := repository.NewMilestoneAttachRepository(s.db).ReplaceItemMilestonesTx(ctx, tx, itemID, milestoneIDs); err != nil {
302+
return err
303+
}
333304

334-
// Keep item, labels, and sync metadata atomic.
335-
if err := s.syncLabels(ctx, tx, config, issue, itemID); err != nil {
336-
return fmt.Errorf("sync labels: %w", err)
337-
}
305+
now := time.Now()
306+
if _, err := tx.Exec(`
307+
INSERT INTO issue_sync_items (
308+
issue_sync_config_id, item_id, github_issue_number, github_issue_id,
309+
github_issue_url, last_synced_at, last_github_updated_at, created_at, updated_at
310+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
311+
`,
312+
config.ID, itemID, issue.Number, issue.ID,
313+
issue.URL, now, issue.UpdatedAt, now, now,
314+
); err != nil {
315+
return fmt.Errorf("insert sync item: %w", err)
316+
}
338317

339-
if err := tx.Commit(); err != nil {
340-
return fmt.Errorf("commit: %w", err)
318+
// Keep item, labels, and sync metadata atomic.
319+
if err := s.syncLabels(ctx, tx, config, issue, itemID); err != nil {
320+
return fmt.Errorf("sync labels: %w", err)
321+
}
322+
return nil
323+
})
324+
if err != nil {
325+
return fmt.Errorf("create item from issue: %w", err)
341326
}
342327

343328
slog.Info("created item from GitHub issue",

0 commit comments

Comments
 (0)