Close the Row/Col position race: unique index plus retry-by-read
(TabID, Position) was a plain index on Row and Col (models.go), not a constraint — EnsureRow/EnsureCol (task 11) are the first insert logic ever written against either table, and a second process (another app instance, a retried background job, a double-clicked import) building its own in-memory rowByPos/colByPos can lose a race this handle's own mutex cannot see: both find no row at a position, both Create, and without a constraint both succeed, leaving two Row records claiming one grid coordinate. Fix: uniqueIndex on (TabID, Position) for both Row and Col, and EnsureRow/EnsureCol treat a Create that violates it as "someone else already created this," recovering by reading the row/col back — the same shape internFormat already uses for Format.Digest's own uniqueIndex, applied to the table that didn't have one. Tested against a real violation, not just the happy path: TestEnsureRowRetriesOnConcurrentCreate and TestEnsureColRetriesOnConcurrentCreate seed a colliding row/col through a path this handle's own bookkeeping never observes (standing in for another process), then assert EnsureRow/EnsureCol recovers the winner's id rather than erroring or creating a duplicate; a count query confirms no duplicate landed. TestRowUniqueIndexRejectsDuplicatePosition pins the constraint itself. Live-fire checked: with the retry removed, the new tests fail with "duplicated key not allowed"; reinstated, they pass — and the log line at internal/sheets/store/handle_grid.go:47 shows the violation actually firing and being recovered from, not just a green happy path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
3 files changed,
+137
−5
internal/sheets/models.go+11 −4internal/sheets/store/handle_grid.go+27 −1internal/sheets/store/handle_grid_race_test.go+99 −0
Intent
diff --git a/internal/sheets/models.go b/internal/sheets/models.go| index 674ceff..0f7ec93 100644 |
| --- a/internal/sheets/models.go |
| +++ b/internal/sheets/models.go |
| @@ -26,17 +26,24 @@ type Tab struct { |
| // never part of a key — so inserting a row above a comment or a range does |
| // not move what they point at. See spec section 4.2 for what this does and |
| // does not buy: it removes reference rewriting for insert and delete only. |
| +// |
| +// (TabID, Position) is a uniqueIndex, not a plain one: at most one Row (or |
| +// Col) may ever claim a given grid coordinate within a tab. Nothing wrote |
| +// Row/Col rows until store.Handle.EnsureRow/EnsureCol (XLSX import), which |
| +// is why this constraint arrives with them rather than with the schema |
| +// itself — see that file's doc comment for the race a plain index would |
| +// leave open across two processes. |
| type Row struct { |
| ID uint64 `gorm:"primaryKey"` |
| - TabID uint64 `gorm:"index:idx_row_tab_pos"` |
| - Position int `gorm:"index:idx_row_tab_pos"` |
| + TabID uint64 `gorm:"uniqueIndex:idx_row_tab_pos"` |
| + Position int `gorm:"uniqueIndex:idx_row_tab_pos"` |
| Height int |
| } |
| type Col struct { |
| ID uint64 `gorm:"primaryKey"` |
| - TabID uint64 `gorm:"index:idx_col_tab_pos"` |
| - Position int `gorm:"index:idx_col_tab_pos"` |
| + TabID uint64 `gorm:"uniqueIndex:idx_col_tab_pos"` |
| + Position int `gorm:"uniqueIndex:idx_col_tab_pos"` |
| Width int |
| } |
diff --git a/internal/sheets/store/handle_grid.go b/internal/sheets/store/handle_grid.go| index fb982cb..1eab3fd 100644 |
| --- a/internal/sheets/store/handle_grid.go |
| +++ b/internal/sheets/store/handle_grid.go |
| @@ -24,6 +24,19 @@ import ( |
| // EnsureRow returns the id of the row at pos, creating it if this tab has |
| // none there yet. Idempotent: a second call for the same pos returns the |
| // same id without writing anything. |
| +// |
| +// (TabID, Position) is a uniqueIndex (models.go), not just a plain one: |
| +// within THIS handle, its own mutex plus rowByPos already make a |
| +// duplicate Create impossible. But a second process — another app |
| +// instance, a retried background job, a double-clicked import — builds |
| +// its own rowByPos and can lose exactly this race: both see no row at |
| +// pos, both Create. Without the constraint that succeeds twice, leaving |
| +// two Row records claiming one grid coordinate. With it, the loser's |
| +// Create fails on the unique violation, which is not treated as a |
| +// failure here: it means someone else already created the row this call |
| +// wanted, so this re-reads it by (TabID, pos) instead of propagating the |
| +// error — the same recovery shape internFormat uses below for |
| +// Format.Digest's own uniqueIndex. |
| func (h *Handle) EnsureRow(pos int) (uint64, error) { |
| h.mu.Lock() |
| defer h.mu.Unlock() |
| @@ -32,6 +45,12 @@ func (h *Handle) EnsureRow(pos int) (uint64, error) { |
| } |
| r := sheets.Row{TabID: h.tabID, Position: pos} |
| if err := h.db.Create(&r).Error; err != nil { |
| + var existing sheets.Row |
| + if err2 := h.db.Where("tab_id = ? AND position = ?", h.tabID, pos).First(&existing).Error; err2 == nil { |
| + h.rowPos[existing.ID] = pos |
| + h.rowByPos[pos] = existing.ID |
| + return existing.ID, nil |
| + } |
| return 0, fmt.Errorf("store: create row at position %d: %w", pos, err) |
| } |
| h.rowPos[r.ID] = pos |
| @@ -39,7 +58,8 @@ func (h *Handle) EnsureRow(pos int) (uint64, error) { |
| return r.ID, nil |
| } |
| -// EnsureCol is EnsureRow's column counterpart. |
| +// EnsureCol is EnsureRow's column counterpart, including the same |
| +// unique-violation-means-someone-else-won-the-race recovery. |
| func (h *Handle) EnsureCol(pos int) (uint64, error) { |
| h.mu.Lock() |
| defer h.mu.Unlock() |
| @@ -48,6 +68,12 @@ func (h *Handle) EnsureCol(pos int) (uint64, error) { |
| } |
| c := sheets.Col{TabID: h.tabID, Position: pos} |
| if err := h.db.Create(&c).Error; err != nil { |
| + var existing sheets.Col |
| + if err2 := h.db.Where("tab_id = ? AND position = ?", h.tabID, pos).First(&existing).Error; err2 == nil { |
| + h.colPos[existing.ID] = pos |
| + h.colByPos[pos] = existing.ID |
| + return existing.ID, nil |
| + } |
| return 0, fmt.Errorf("store: create col at position %d: %w", pos, err) |
| } |
| h.colPos[c.ID] = pos |
diff --git a/internal/sheets/store/handle_grid_race_test.go b/internal/sheets/store/handle_grid_race_test.go| new file mode 100644 |
| index 0000000..d490535 |
| --- /dev/null |
| +++ b/internal/sheets/store/handle_grid_race_test.go |
| @@ -0,0 +1,99 @@ |
| +package store_test |
| + |
| +import ( |
| + "testing" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets" |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// TestEnsureRowRetriesOnConcurrentCreate simulates the race a second |
| +// process can win: a Row lands at (tabID, pos) through a path this |
| +// handle's own rowByPos never observes (a raw db.Create standing in for |
| +// another process's Handle), so when THIS handle's EnsureRow reaches the |
| +// same position it must lose the Create to the uniqueIndex on |
| +// (TabID, Position) — models.go — and recover by reading the row back, |
| +// not by erroring or creating a second, colliding Row. |
| +func TestEnsureRowRetriesOnConcurrentCreate(t *testing.T) { |
| + db, sealer, tabID := emptyTab(t) |
| + h, err := store.Open(db, sealer, tabID) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + defer h.Release() |
| + |
| + const pos = 3 |
| + winner := sheets.Row{TabID: tabID, Position: pos} |
| + if err := db.Create(&winner).Error; err != nil { |
| + t.Fatalf("seed concurrent row: %v", err) |
| + } |
| + |
| + got, err := h.EnsureRow(pos) |
| + if err != nil { |
| + t.Fatalf("EnsureRow after a concurrent create: %v", err) |
| + } |
| + if got != winner.ID { |
| + t.Fatalf("EnsureRow(%d) = %d, want the concurrently-created row's id %d", pos, got, winner.ID) |
| + } |
| + |
| + var count int64 |
| + if err := db.Model(&sheets.Row{}).Where("tab_id = ? AND position = ?", tabID, pos).Count(&count).Error; err != nil { |
| + t.Fatalf("count rows at position: %v", err) |
| + } |
| + if count != 1 { |
| + t.Fatalf("%d rows claim position %d, want exactly 1 — the retry created a duplicate", count, pos) |
| + } |
| + |
| + // A subsequent call must still be idempotent, now served from the |
| + // handle's own (now-populated) rowByPos rather than hitting the db. |
| + got2, err := h.EnsureRow(pos) |
| + if err != nil || got2 != winner.ID { |
| + t.Fatalf("EnsureRow(%d) again = %d, %v, want %d, nil", pos, got2, err, winner.ID) |
| + } |
| +} |
| + |
| +// EnsureCol's counterpart to the above. |
| +func TestEnsureColRetriesOnConcurrentCreate(t *testing.T) { |
| + db, sealer, tabID := emptyTab(t) |
| + h, err := store.Open(db, sealer, tabID) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + defer h.Release() |
| + |
| + const pos = 3 |
| + winner := sheets.Col{TabID: tabID, Position: pos} |
| + if err := db.Create(&winner).Error; err != nil { |
| + t.Fatalf("seed concurrent col: %v", err) |
| + } |
| + |
| + got, err := h.EnsureCol(pos) |
| + if err != nil { |
| + t.Fatalf("EnsureCol after a concurrent create: %v", err) |
| + } |
| + if got != winner.ID { |
| + t.Fatalf("EnsureCol(%d) = %d, want the concurrently-created col's id %d", pos, got, winner.ID) |
| + } |
| + |
| + var count int64 |
| + if err := db.Model(&sheets.Col{}).Where("tab_id = ? AND position = ?", tabID, pos).Count(&count).Error; err != nil { |
| + t.Fatalf("count cols at position: %v", err) |
| + } |
| + if count != 1 { |
| + t.Fatalf("%d cols claim position %d, want exactly 1 — the retry created a duplicate", count, pos) |
| + } |
| +} |
| + |
| +// TestRowUniqueIndexRejectsDuplicatePosition pins the constraint itself, |
| +// independent of EnsureRow/EnsureCol's recovery: two Row records cannot |
| +// claim the same (TabID, Position) at the database level at all, which is |
| +// the guarantee the retry-by-read logic above depends on existing. |
| +func TestRowUniqueIndexRejectsDuplicatePosition(t *testing.T) { |
| + db, _, tabID := emptyTab(t) |
| + if err := db.Create(&sheets.Row{TabID: tabID, Position: 0}).Error; err != nil { |
| + t.Fatalf("first create: %v", err) |
| + } |
| + if err := db.Create(&sheets.Row{TabID: tabID, Position: 0}).Error; err == nil { |
| + t.Fatal("second Row at the same (TabID, Position) succeeded, want a uniqueIndex violation") |
| + } |
| +} |