fix: catch ErrNeedsFullRecalculation in task creation position conflict resolution

resolvePositionConflictsAfterInsert now falls back to a full position
recalculation when resolveTaskPositionConflicts returns
ErrNeedsFullRecalculation, instead of bubbling the error up as HTTP 500.
This mirrors the existing fallback logic in the CLI repair command.

Ref: #2550
This commit is contained in:
kolaente 2026-04-08 10:28:25 +02:00 committed by kolaente
parent 36cec5ccca
commit 2014343557
2 changed files with 63 additions and 0 deletions

View File

@ -600,12 +600,20 @@ func resolveTaskPositionConflicts(s *xorm.Session, projectViewID int64, conflict
// resolvePositionConflictsAfterInsert checks a batch of newly inserted task positions
// for conflicts (duplicate position values within the same view) and resolves them.
// This is called after bulk-inserting positions during task creation.
// If resolveTaskPositionConflicts returns ErrNeedsFullRecalculation for a view,
// it falls back to a full recalculation of all positions in that view.
func resolvePositionConflictsAfterInsert(s *xorm.Session, positions []*TaskPosition) error {
// Track which (viewID, position) pairs we've already checked to avoid
// resolving the same conflict group twice.
checked := make(map[int64]map[float64]bool)
// Track views that have already been fully recalculated so we skip
// further conflict checks for them.
recalculated := make(map[int64]bool)
for _, pos := range positions {
if recalculated[pos.ProjectViewID] {
continue
}
if checked[pos.ProjectViewID] != nil && checked[pos.ProjectViewID][pos.Position] {
continue
}
@ -624,6 +632,15 @@ func resolvePositionConflictsAfterInsert(s *xorm.Session, positions []*TaskPosit
}
err = resolveTaskPositionConflicts(s, pos.ProjectViewID, conflicts)
if IsErrNeedsFullRecalculation(err) {
view := &ProjectView{ID: pos.ProjectViewID}
err = recalculateTaskPositionsForRepair(s, view)
if err != nil {
return err
}
recalculated[pos.ProjectViewID] = true
continue
}
if err != nil {
return err
}

View File

@ -547,3 +547,49 @@ func TestSetTaskInBucketInViewsResolvesConflicts(t *testing.T) {
assert.NotEqual(t, p1.Position, p2.Position,
"Positions should be unique after conflict resolution")
}
func TestResolvePositionConflictsAfterInsertFallsBackToRecalculation(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
viewID := int64(1)
// Clear existing positions for this view
_, err := s.Where("project_view_id = ?", viewID).Delete(&TaskPosition{})
require.NoError(t, err)
// Set up extremely tight spacing that forces ErrNeedsFullRecalculation:
// Two existing positions with a gap smaller than MinPositionSpacing * (conflicts+1)
basePos := 100.0
tinyGap := MinPositionSpacing * 0.1 // Much smaller than needed
_, err = s.Insert(&TaskPosition{TaskID: 800, ProjectViewID: viewID, Position: basePos})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 801, ProjectViewID: viewID, Position: basePos + tinyGap})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 802, ProjectViewID: viewID, Position: basePos + tinyGap})
require.NoError(t, err)
_, err = s.Insert(&TaskPosition{TaskID: 803, ProjectViewID: viewID, Position: basePos + 2*tinyGap})
require.NoError(t, err)
// The conflicting positions that would trigger ErrNeedsFullRecalculation
conflictPositions := []*TaskPosition{
{TaskID: 801, ProjectViewID: viewID, Position: basePos + tinyGap},
{TaskID: 802, ProjectViewID: viewID, Position: basePos + tinyGap},
}
// This should NOT return an error -- it should fall back to full recalculation
err = resolvePositionConflictsAfterInsert(s, conflictPositions)
require.NoError(t, err)
// Verify all positions are now unique
var positions []*TaskPosition
err = s.Where("project_view_id = ?", viewID).OrderBy("position ASC").Find(&positions)
require.NoError(t, err)
seen := make(map[float64]bool)
for _, p := range positions {
assert.False(t, seen[p.Position], "duplicate position found: %f for task %d", p.Position, p.TaskID)
seen[p.Position] = true
}
}