Files
multica/server/migrations/093_issue_position_backfill.up.sql
Naiyuan Qing 0127d6a258 fix(issues): address MUL-2314 reviewer notes 1-4
#1 sort default: missing sort_by, unknown sort_by, and illegal
sort_direction all collapse to created_at DESC, id DESC (was ASC).
Order-asserting test cases added for every degenerate input.

#2 onboarding position: extract nextIssuePosition helper. Welcome
issue + sub-issues + the existing CreateIssue path all route through
it instead of inheriting Go's Position=0 default. This consolidates
the three create call-sites so a fourth path cannot regress.

#3 rebalance hot path: replace full-bucket scan with a neighbor-only
SQL query (GetIssueNeighborGap) used by MaybeEnqueueRebalance. Hot
path stays O(1); worker still does the full bucket scan when actually
rebalancing. Bucket-edge issues use COALESCE sentinels so missing
neighbours never trigger spurious rebalances.

#4 migration: up is now order-preserving (position ASC, created_at
DESC, id DESC) and idempotent (skips buckets that are already sparse).
down drops only the index; data restore is per runbook snapshot so a
destructive UPDATE issue SET position = 0 cannot wipe drag edits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 13:58:45 +08:00

57 lines
2.8 KiB
SQL

-- Backfill the `position` column so existing issues get sparse, deterministic
-- values inside each (workspace_id, status) bucket. Until now all rows wrote
-- `position = 0`, which collapsed the fractional indexing scheme (the (prev+next)/2
-- midpoint between two zeros is still zero, so mid-list drag-drop was a no-op).
--
-- Two contracts this migration MUST satisfy (MUL-2314 reviewer note #4):
--
-- 1. Order-preserving. Re-rank within each bucket by
-- `position ASC, created_at DESC, id DESC` — the same tail the runtime
-- query uses for "newest first under legacy position-asc clients".
-- Buckets where users already drag-edited positions (so they have varying
-- values, not all-zero) keep their ordering; we just respace them onto
-- sparse floats.
--
-- 2. Idempotent. Safe to re-run on a partially-migrated database. A bucket
-- whose positions are already sparse (MIN(position) < MAX(position))
-- is skipped entirely. After one successful pass every dense bucket
-- becomes sparse, so subsequent runs are no-ops. This matters because
-- deploy retries and side-by-side migration probes (`migrate-up --dry-run`
-- then real) used to overwrite the first run's output, including any
-- manual drag-edits that landed between probe and real.
--
-- Batching: there is no LIMIT here on purpose. The migration runs once during
-- deploy windowing, not on a hot loop. For workspaces that have grown large
-- enough to warrant batched backfill, the project runbook documents running this
-- SQL in chunks of 1000-10000 rows during a low-traffic window with a snapshot
-- of `position` taken beforehand so a one-shot rollback is possible.
WITH dense_buckets AS (
-- A bucket is "dense" (needs backfill) iff every row shares the same
-- position value — typically all zeros from the pre-MUL-2314 default.
-- Pre-existing sparse buckets (any drag-edited workspace, or a re-run
-- on the output of an earlier pass) are skipped.
SELECT workspace_id, status
FROM issue
GROUP BY workspace_id, status
HAVING MIN(position) = MAX(position)
),
ranked AS (
SELECT i.id,
ROW_NUMBER() OVER (
PARTITION BY i.workspace_id, i.status
ORDER BY i.position ASC, i.created_at DESC, i.id DESC
)::float8 AS new_pos
FROM issue i
JOIN dense_buckets d ON d.workspace_id = i.workspace_id AND d.status = i.status
)
UPDATE issue
SET position = ranked.new_pos
FROM ranked
WHERE issue.id = ranked.id;
-- Composite index used by GetMinIssuePosition / GetIssueNeighborGap on the
-- create path and by the bucket-scoped rebalance worker. `IF NOT EXISTS` so
-- this is safe to re-run if an earlier deploy left the index behind.
CREATE INDEX IF NOT EXISTS idx_issue_workspace_status_position
ON issue (workspace_id, status, position);