{"record":{"id":"6dec1ee78c74631c","repo":"multica-ai/multica","slug":"move-anchors-are-too-close-refresh-and-retry","errorCode":null,"errorMessage":"move anchors are too close; refresh and retry","messagePattern":"move anchors are too close; refresh and retry","errorType":"http","errorClass":null,"httpStatus":409,"severity":"warning","filePath":"server/internal/handler/issue_move.go","lineNumber":199,"sourceCode":"\t\t\twriteError(w, http.StatusBadRequest, \"move anchor not found in this workspace\")\n\t\t} else {\n\t\t\twriteIssueTableQueryFailure(w, r, \"failed to resolve move anchor\")\n\t\t}\n\t\treturn nil, false\n\t}\n\treturn &position, true\n}\n\nfunc issueMovePosition(current float64, before, after *float64) (float64, error) {\n\tswitch {\n\tcase before != nil && after != nil:\n\t\tif !(*before < *after) {\n\t\t\treturn 0, errors.New(\"move anchors are stale or out of order\")\n\t\t}\n\t\tposition := *before + (*after-*before)/2\n\t\tif !(position > *before && position < *after) ||\n\t\t\tmath.IsInf(position, 0) || math.IsNaN(position) {\n\t\t\treturn 0, errors.New(\"move anchors are too close; refresh and retry\")\n\t\t}\n\t\treturn position, nil\n\tcase before != nil:\n\t\tposition := *before + 1\n\t\tif math.IsInf(position, 0) || math.IsNaN(position) {\n\t\t\treturn 0, errors.New(\"move position is out of range\")\n\t\t}\n\t\treturn position, nil\n\tcase after != nil:\n\t\tposition := *after - 1\n\t\tif math.IsInf(position, 0) || math.IsNaN(position) {\n\t\t\treturn 0, errors.New(\"move position is out of range\")\n\t\t}\n\t\treturn position, nil\n\tdefault:\n\t\treturn current, nil\n\t}\n}","sourceCodeStart":181,"sourceCodeEnd":217,"githubUrl":"https://github.com/multica-ai/multica/blob/2c0912b6ec764b373d44eeea1e80f0d9f11ab417/server/internal/handler/issue_move.go#L181-L217","documentation":"issueMovePosition places the moved issue at the midpoint between the two anchors: position = before + (after-before)/2. After repeated inserts into the same gap, floating-point precision runs out and the midpoint can equal an anchor or become Inf/NaN; the check position > before && position < after catches this. The error tells the client the gap is exhausted and it must refresh so the server (or a rebalance) can re-space positions.","triggerScenarios":"Drag-and-drop the same narrow gap hundreds of times so consecutive float64 positions differ by less than one ULP; the midpoint computation rounds to before or after exactly, failing the strict inequality; mathematically (after-before)/2 underflows to 0 at extreme magnitudes.","commonSituations":"Long-lived boards where users always insert at the same spot (top of list) without full reordering; test suites performing thousands of inserts into one gap; position values grown large after many +1/-1 appends (precision at 2^53 scale).","solutions":["On this error, refetch the list and retry once — fresh anchors will have a usable gap (and any rebalancing will have re-spaced values).","Prefer moving to the very top/bottom (single-anchor +1/-1 form) instead of repeatedly subdividing one gap.","If it recurs constantly for the same gap, trigger/request a position rebalance for the list or spread inserts across neighboring gaps.","Client-side, avoid issuing a move whose anchors' positions are already adjacent in float terms."],"exampleFix":"// before\nasync function drag(issueId, beforePos, afterPos) {\n  return api.move(issueId, {before: beforePos, after: afterPos});\n}\n\n// after\nasync function drag(issueId, beforeId, afterId) {\n  try { return await api.move(issueId, {before: beforeId, after: afterId}); }\n  catch (e) {\n    if (e.message.includes('too close')) {\n      await refetchList(); // get re-spaced positions\n      return api.move(issueId, {before: beforeId, after: afterId}); // one retry\n    }\n    throw e;\n  }\n}","handlingStrategy":"retry","validationCode":"function gapIsUsable(beforePos, afterPos) {\n  const mid = beforePos + (afterPos - beforePos) / 2;\n  return mid > beforePos && mid < afterPos && Number.isFinite(mid);\n}\n\n// skip the request if the local gap is already exhausted\nif (before && after && !gapIsUsable(before.position, after.position)) {\n  await refetchIssues(); // get re-spaced positions before attempting the move\n}","typeGuard":"const hasFloatRoom = (a, b) => b > a && (a + (b - a) / 2) < b;","tryCatchPattern":"try {\n  await api.moveIssue(issueId, { beforeId, afterId });\n} catch (e) {\n  if (e.status === 400 && /too close/.test(e.message)) {\n    await refetchIssues(); // server-side re-spacing makes the gap usable again\n    await api.moveIssue(issueId, { beforeId, afterId });\n  } else {\n    throw e;\n  }\n}","preventionTips":["Prefer top/bottom drops (single anchor) over repeatedly subdividing the same gap.","Check gap usability locally before sending, using the same midpoint math as the server.","If a specific gap keeps failing, spread inserts into neighboring gaps or request a list rebalance."],"tags":["ordering","float-precision","drag-and-drop","retryable"],"backgroundTag":null,"analyzedSha":"2c0912b6ec764b373d44eeea1e80f0d9f11ab417","analyzedAt":"2026-08-15T13:25:18.241Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}