Skip to content

Commit 563dd92

Browse files
authored
feat(keyviz): per-cell conflict wire format + SPA per-cell hatching (Phase 2-C+ PR-3d) (#824)
## Summary Promotes the row-level `conflict` bool to a per-cell `Conflicts []bool` on the JSON `KeyVizRow`, so the SPA hatches only the columns that saw a within-term fan-out merge disagreement instead of the whole row. The per-cell conflict bit is **already computed** in the merge accumulator (`cellMergeAcc.hasConflict`); PR-3c (#822) collapsed it to a row-level OR. This closes that gap and aligns with parent design §9.1 ("the cell is surfaced with `conflict=true`"). Design doc: `docs/design/2026_05_25_proposed_keyviz_per_cell_conflict.md` (committed first). - `internal/admin/keyviz_handler.go` — `KeyVizRow` gains `Conflicts []bool` (`json:"conflicts,omitempty"`), parallel to `Values[]`. - `internal/admin/keyviz_fanout.go` — `resolveRowMergeAcc` stamps `Conflicts[j]` per write cell; row-level `Conflict` stays the OR for older clients. - `web/admin/src/pages/KeyViz.tsx` — `ConflictOverlay` hatches individual cells when `conflicts[]` is present, falls back to whole-row hatch when only the row-level `conflict` flag is set (legacy server). - `web/admin/src/api/client.ts` — `KeyVizRow` type gains `conflicts?: boolean[]`. ## Scope notes - **JSON-only, no proto/gRPC change.** `proto.KeyVizRow` has never carried a conflict field: conflict is a fan-out *merge* artifact, and fan-out is JSON-over-HTTP peer aggregation; the single-node gRPC `GetKeyVizMatrix` never merges. Adding `conflicts` to the proto would be a field with no producer/consumer. - **No algorithm change** — PR-3c's `(group, term)` dedupe + fallback-max path is untouched; PR-3d only widens how the already-computed per-cell bit reaches the client. - **No new flag.** `omitempty` keeps `conflicts` off the wire on the single-node / no-fan-out path. ## Compatibility | Server | SPA | Behaviour | |---|---|---| | new | new | per-cell hatch | | new | old | whole-row hatch (reads only `conflict`) | | old | new | falls back to whole-row hatch | | old | old | whole-row hatch (unchanged) | ## Self-review (five lenses) 1. **Data loss** — none; read-only admin path, no Raft/FSM/Pebble interaction. `conflicts[]` is derived from existing merge state. 2. **Concurrency / distributed** — none new; merge runs synchronously after the parallel peer fetch, no new shared mutable state. Caller-audited `resolveRowMergeAcc` (one caller) and `.Conflict`/`.Conflicts` (set only in `keyviz_fanout.go`; SPA is the only consumer, via JSON). 3. **Performance** — one `make([]bool, width)` per merged write row (bounded by the 1024-row budget); the conflict bit was already computed per cell, no extra pass. `omitempty` avoids wire growth on quiet matrices. 4. **Data consistency** — row-level `conflict` stays the exact OR of `conflicts[]`; per-cell is strictly more precise. `(group, term)` dedupe unchanged. 5. **Test coverage** — added `TestMergeKeyVizMatricesPerCellConflictMarksOnlyAffectedColumn` (only the disagreeing column flagged, row-level OR still true) and `TestMergeKeyVizMatricesReadsLeaveConflictsNil`. ## Test evidence - `go test -race -count=1 ./internal/admin/...` — pass - `golangci-lint run internal/admin/...` — 0 issues - `cd web/admin && npm run build` (`tsc -b && vite build`) — pass (type-check clean) - ⚠️ SPA rendering not verified in a live browser (no display in this environment); change is rendering-only and type-checked. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Enhanced KeyViz to display per-cell conflict detection with granular heatmap overlay visualization, providing more detailed conflict reporting for individual cells while maintaining backward compatibility. * **Tests** * Added comprehensive test coverage for per-cell conflict detection and JSON serialization behavior. * **Documentation** * Added design documentation for per-cell conflict reporting implementation. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/bootjp/elastickv/pull/824?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 9cac82a + e957e98 commit 563dd92

6 files changed

Lines changed: 340 additions & 43 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
---
2+
status: proposed
3+
phase: 2-C+
4+
parent_design: docs/admin_ui_key_visualizer_design.md
5+
date: 2026-05-25
6+
---
7+
8+
# KeyViz Per-Cell Conflict Wire Format (Phase 2-C+ PR-3d)
9+
10+
## 1. Background
11+
12+
Phase 2-C+ PR-3c (#822) replaced the legacy §4.2 row-level max-merge
13+
with the canonical §9.1 `(raftGroupID, leaderTerm)` per-cell dedupe:
14+
the fan-out aggregator now collapses write samples to one value per
15+
`(bucketID, raftGroupID, leaderTerm, windowStart)` and sums across
16+
distinct terms for the same `(group, window)`. When two sources
17+
report different non-zero values for the **same** `(group, term,
18+
column)` tuple — a Raft-invariant violation, since at most one leader
19+
exists per term per group — the merge flags a conflict.
20+
21+
That conflict detection already happens **per cell** inside the merge
22+
accumulator (`cellMergeAcc.hasConflict()` in
23+
`internal/admin/keyviz_fanout.go`). But the wire format only carries a
24+
single **row-level** `conflict bool`: `resolveRowMergeAcc` ORs every
25+
cell's conflict into one boolean for the whole row. The SPA's
26+
`ConflictOverlay` then hatches the **entire row**, even when the
27+
disagreement was confined to one column during a brief leadership
28+
flip.
29+
30+
Parent design §9.1 actually describes the conflict signal at cell
31+
granularity — *"if they differ, the cell is surfaced with
32+
`conflict=true`"* and *"The heatmap hatches rows or time windows whose
33+
expected source node failed."* The row-level collapse was an explicit
34+
PR-3c simplification (see the deferral note in
35+
`keyviz_handler.go`'s `KeyVizRow.Conflict` doc comment); PR-3d closes
36+
that gap.
37+
38+
## 2. Scope
39+
40+
### 2.1 In scope
41+
42+
- Add a per-cell `Conflicts []bool` field to the **JSON** `KeyVizRow`
43+
(`internal/admin/keyviz_handler.go`), parallel to `Values[]`
44+
`Conflicts[j]` is true when column `j` saw a within-term
45+
disagreement during fan-out merge.
46+
- Stamp `Conflicts[j]` from the existing per-cell
47+
`cellMergeAcc.hasConflict()` in `resolveRowMergeAcc`.
48+
- Keep the row-level `Conflict bool` on the wire as the OR of all
49+
cells, so an **older SPA** that only reads `conflict` keeps hatching
50+
the whole row (no behavioural regression).
51+
- SPA `ConflictOverlay`: hatch the **individual cells** where
52+
`conflicts[j]` is true. When a response carries only row-level
53+
`conflict` (legacy server, no `conflicts` array), fall back to the
54+
current whole-row hatch.
55+
- Tests: per-cell conflict assertions in `keyviz_fanout_test.go`.
56+
57+
### 2.2 Out of scope / non-goals
58+
59+
- **No proto / gRPC change.** The `proto.KeyVizRow` (gRPC
60+
`GetKeyVizMatrix`) has **never** carried a conflict field. Conflict
61+
is a fan-out *merge* artifact, and fan-out is implemented as
62+
JSON-over-HTTP peer aggregation (`KeyVizFanout.Run`); the single-node
63+
gRPC RPC never merges and therefore never produces a conflict.
64+
Adding `conflicts` to the proto would introduce a field with no
65+
producer and no consumer — speculative, so explicitly excluded.
66+
- No change to the merge *algorithm* — PR-3c's `(group, term)` dedupe
67+
and fallback-max path are unchanged. PR-3d only widens how the
68+
already-computed per-cell conflict bit reaches the client.
69+
- No new `--flag`; conflict reporting is intrinsic to fan-out and was
70+
already on by default whenever fan-out is configured.
71+
72+
## 3. Wire format
73+
74+
`KeyVizRow` (JSON) gains:
75+
76+
```go
77+
// Conflicts[j] is true when fan-out merge saw two sources report
78+
// different non-zero values for the same (bucket, raft_group_id,
79+
// leader_term, column j) tuple. Parallel to Values[]; allocated
80+
// lazily so it is nil whenever the row had no conflict (single-node,
81+
// legacy server, OR a cleanly merged row) — omitempty then keeps it
82+
// off the wire. Otherwise len == len(Values). The row-level Conflict
83+
// bool remains the OR of this slice for older clients.
84+
Conflicts []bool `json:"conflicts,omitempty"`
85+
```
86+
87+
Compatibility matrix:
88+
89+
| Server | Client (SPA) | Behaviour |
90+
|---|---|---|
91+
| new (emits `conflicts[]` + `conflict`) | new | per-cell hatch |
92+
| new | old (reads only `conflict`) | whole-row hatch (unchanged) |
93+
| old (emits only `conflict`) | new | falls back to whole-row hatch |
94+
| old | old | whole-row hatch (unchanged) |
95+
96+
`omitempty` keeps the field off the wire for the single-node /
97+
no-fan-out path (the merge that produces conflicts only runs with ≥2
98+
matrices), so quiet deployments see no payload growth.
99+
100+
## 4. Merge changes
101+
102+
`resolveRowMergeAcc` (`keyviz_fanout.go`) already iterates every cell
103+
to resolve writes. It will additionally:
104+
105+
1. On the first conflicting cell, **lazily** allocate
106+
`row.Conflicts = make([]bool, width)` — never up front, so a
107+
cleanly merged row keeps `Conflicts == nil` and `omitempty` drops
108+
it from the wire (a non-nil `[]bool` is never "empty" for
109+
`omitempty`, so eager allocation would emit `[false,...]` on every
110+
merged write row and balloon the payload).
111+
2. Set `row.Conflicts[j] = true` and `row.Conflict = true` for that
112+
cell.
113+
114+
The read path (`useGroupTermDedupe == false`) never sets conflict, so
115+
it leaves `Conflicts` nil — consistent with today's row-level
116+
behaviour.
117+
118+
## 5. SPA changes
119+
120+
`ConflictOverlay` currently emits one full-width `<rect>` per
121+
conflicting row. PR-3d:
122+
123+
- When `row.conflicts` is present, emit one `<rect>` per conflicting
124+
**cell** at `x = j * cellW`, `width = cellW`, `y = i * cellH`.
125+
- When `row.conflicts` is absent but `row.conflict` is true (legacy
126+
server), keep the full-row rect.
127+
- The `RowDetail` "conflict" pill keeps using the row-level
128+
`row.conflict` (it is a row-scoped summary, unchanged).
129+
130+
The hatch `<pattern>` is unchanged. Per-cell rects reuse the same
131+
`fill="url(#keyviz-conflict-hatch)"`.
132+
133+
## 6. Self-review (per CLAUDE.md five lenses)
134+
135+
1. **Data loss** — none. Read-only admin path; no Raft / FSM / Pebble
136+
interaction. `conflicts[]` is derived from existing merge state.
137+
2. **Concurrency / distributed** — none new. Merge runs synchronously
138+
after the parallel peer fetch; no shared mutable state added.
139+
3. **Performance** — one `make([]bool, width)` only for rows that
140+
actually conflict (lazy allocation), bounded by the 1024-row
141+
budget; the conflict bit was already computed per cell, so no extra
142+
passes. Lazy alloc + `omitempty` means clean rows add zero wire
143+
bytes and zero allocations.
144+
4. **Data consistency** — the row-level `conflict` stays the exact OR
145+
of `conflicts[]`, so the coarse signal is unchanged; per-cell is
146+
strictly more precise. No change to `(group, term)` dedupe.
147+
5. **Test coverage** — new table cases assert `Conflicts[j]` is true
148+
only at the conflicting column and that the row-level OR still
149+
fires. SPA change is rendering-only; covered by the existing
150+
manual heatmap check.
151+
152+
## 7. Rollout
153+
154+
Forwards- and backwards-compatible (see §3 matrix). No flag, no schema,
155+
no state on disk. Mixed-version clusters: a new aggregator merging an
156+
old peer's matrix simply gets no `conflicts[]` from that peer and ORs
157+
its row-level `conflict` as before; a new SPA against an old server
158+
falls back to whole-row hatch.

internal/admin/keyviz_fanout.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@ type FanoutNodeStatus struct {
108108
//
109109
// - Reads / read_bytes: sum across nodes (each node served distinct
110110
// follower reads).
111-
// - Writes / write_bytes: max across nodes; when the per-cell values
112-
// disagree we set Conflict=true on the row (best-effort dedup
113-
// during a leadership flip; the canonical (raftGroupID, leaderTerm)
114-
// dedup lands in Phase 2-C+ when we extend the wire format).
111+
// - Writes / write_bytes: §9.1 canonical (raftGroupID, leaderTerm)
112+
// dedupe; when two sources disagree within the same (group, term,
113+
// column) we set Conflicts[column]=true (and the row-level OR
114+
// Conflict) so the SPA can hatch the affected cell.
115115
type KeyVizFanout struct {
116116
self string
117117
peers []string
@@ -475,8 +475,9 @@ func buildKeyVizPeerURL(peer string, params keyVizParams) (string, error) {
475475
// max within the same (group, term) (canonical Raft invariant:
476476
// at most one leader per term per group), then SUM across
477477
// distinct LeaderTerm values for the same (group, column).
478-
// Surface conflict=true at the row level when ≥2 sources
479-
// disagree within the SAME (group, term, column). Fall back to
478+
// Surface Conflicts[column]=true (and the row-level OR Conflict)
479+
// when ≥2 sources disagree within the SAME (group, term, column).
480+
// Fall back to
480481
// legacy max-merge (§4.2) for any cell where at least one source
481482
// reports a zero/unknown identity (RaftGroupID=0 or
482483
// LeaderTerm=0) so a legacy peer that doesn't yet emit the
@@ -716,9 +717,9 @@ func (c *cellMergeAcc) recordTermContribution(key termKey, value uint64) {
716717
// resolveRowMergeAcc materialises a KeyVizRow from the accumulator.
717718
// For reads: cell.sum + last identity. For writes: either §9.1 sum
718719
// across (group, term) or fallback max-merge when hasUnknownTerm.
719-
// Sets row-level Conflict to any-cell-saw-conflict — Phase 2-C+
720-
// PR-3c keeps the wire-level conflict at row granularity; future
721-
// PR-3d may promote it to per-cell.
720+
// The write path stamps per-cell Conflicts[j] (Phase 2-C+ PR-3d) and
721+
// keeps row-level Conflict as their OR so an older SPA that only reads
722+
// `conflict` still hatches the whole row.
722723
func resolveRowMergeAcc(acc *rowMergeAcc, useGroupTermDedupe bool) KeyVizRow {
723724
width := len(acc.cells)
724725
row := KeyVizRow{
@@ -738,6 +739,14 @@ func resolveRowMergeAcc(acc *rowMergeAcc, useGroupTermDedupe bool) KeyVizRow {
738739
if useGroupTermDedupe {
739740
row.Values[j], row.RaftGroupIDs[j], row.LeaderTerms[j] = c.resolveWrite()
740741
if c.hasConflict() {
742+
// Allocate Conflicts lazily so a clean row keeps it nil
743+
// and `json:"conflicts,omitempty"` omits it — otherwise
744+
// every merged write row would serialize a full
745+
// [false,...] array and balloon the wire payload.
746+
if row.Conflicts == nil {
747+
row.Conflicts = make([]bool, width)
748+
}
749+
row.Conflicts[j] = true
741750
row.Conflict = true
742751
}
743752
} else {

internal/admin/keyviz_fanout_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ func TestMergeKeyVizMatricesGroupTermConflictWithinSameTerm(t *testing.T) {
203203
require.Equal(t, []uint64{55}, merged.Rows[0].Values, "within-term disagreement keeps the larger value")
204204
require.True(t, merged.Rows[0].Conflict,
205205
"§9.1: within-(group, term) disagreement must raise the conflict flag — Raft invariant says one leader per term per group, so disagreement signals a real divergence operators need to see")
206+
require.Equal(t, []bool{true}, merged.Rows[0].Conflicts,
207+
"the per-cell slice must flag the single conflicting column, not just the row-level OR")
206208
}
207209

208210
// TestMergeKeyVizMatricesGroupTermFallbackOnUnknownTerm pins the
@@ -234,6 +236,8 @@ func TestMergeKeyVizMatricesGroupTermFallbackOnUnknownTerm(t *testing.T) {
234236
require.Equal(t, []uint64{50}, merged.Rows[0].Values,
235237
"unknown-term fallback returns max-merge (50), not sum (80) — summing across an unknown term risks double-counting overlapping windows")
236238
require.True(t, merged.Rows[0].Conflict, "fallback path raises conflict when ≥2 distinct non-zero values were seen")
239+
require.Equal(t, []bool{true}, merged.Rows[0].Conflicts,
240+
"the per-cell slice must flag the conflicting column under the fallback path too")
237241
}
238242

239243
// TestMergeKeyVizMatricesFallbackPreservesKnownTermWinnerIdentity
@@ -307,6 +311,90 @@ func TestMergeKeyVizMatricesPerCellIdentityMatchesValueOwner(t *testing.T) {
307311
"col0's identity comes from exLeader (term 42, won 50 vs 0); col1's identity comes from newLeader (term 43, won 80 vs 0)")
308312
}
309313

314+
// TestMergeKeyVizMatricesPerCellConflictMarksOnlyAffectedColumn pins
315+
// PR-3d: per-cell Conflicts[] must flag ONLY the column that saw a
316+
// within-term disagreement, not the whole row. col0 disagrees
317+
// (30 vs 55 for the same group/term), col1 agrees (10 == 10), so
318+
// Conflicts must be [true, false] while the row-level OR Conflict
319+
// stays true for older clients.
320+
func TestMergeKeyVizMatricesPerCellConflictMarksOnlyAffectedColumn(t *testing.T) {
321+
t.Parallel()
322+
col := []int64{1_700_000_000_000, 1_700_000_001_000}
323+
a := KeyVizMatrix{
324+
ColumnUnixMs: col,
325+
Series: keyVizSeriesWrites,
326+
Rows: []KeyVizRow{
327+
{BucketID: "route:9", Values: []uint64{30, 10}, RaftGroupIDs: []uint64{7, 7}, LeaderTerms: []uint64{42, 42}},
328+
},
329+
}
330+
b := KeyVizMatrix{
331+
ColumnUnixMs: col,
332+
Series: keyVizSeriesWrites,
333+
Rows: []KeyVizRow{
334+
{BucketID: "route:9", Values: []uint64{55, 10}, RaftGroupIDs: []uint64{7, 7}, LeaderTerms: []uint64{42, 42}},
335+
},
336+
}
337+
merged := mergeKeyVizMatrices([]KeyVizMatrix{a, b}, keyVizSeriesWrites)
338+
require.Len(t, merged.Rows, 1)
339+
require.Equal(t, []uint64{55, 10}, merged.Rows[0].Values, "within-term disagreement keeps the larger value per cell")
340+
require.Equal(t, []bool{true, false}, merged.Rows[0].Conflicts,
341+
"only col0 disagreed (30 vs 55); col1 agreed (10 == 10) so it must not be hatched")
342+
require.True(t, merged.Rows[0].Conflict,
343+
"row-level Conflict stays the OR of Conflicts[] so an older SPA still hatches the row")
344+
}
345+
346+
// TestMergeKeyVizMatricesWritesWithoutConflictLeaveConflictsNil pins
347+
// the lazy-allocation contract: a write merge with no within-term
348+
// disagreement (stable leader, one source non-zero) must leave
349+
// Conflicts nil so json:"conflicts,omitempty" omits it. Allocating a
350+
// full [false,...] array per clean row would balloon the wire payload.
351+
func TestMergeKeyVizMatricesWritesWithoutConflictLeaveConflictsNil(t *testing.T) {
352+
t.Parallel()
353+
col := []int64{1_700_000_000_000, 1_700_000_001_000}
354+
leader := KeyVizMatrix{
355+
ColumnUnixMs: col,
356+
Series: keyVizSeriesWrites,
357+
Rows: []KeyVizRow{
358+
{BucketID: "route:9", Values: []uint64{30, 40}, RaftGroupIDs: []uint64{7, 7}, LeaderTerms: []uint64{42, 42}},
359+
},
360+
}
361+
follower := KeyVizMatrix{
362+
ColumnUnixMs: col,
363+
Series: keyVizSeriesWrites,
364+
Rows: []KeyVizRow{
365+
{BucketID: "route:9", Values: []uint64{0, 0}, RaftGroupIDs: []uint64{7, 7}, LeaderTerms: []uint64{42, 42}},
366+
},
367+
}
368+
merged := mergeKeyVizMatrices([]KeyVizMatrix{leader, follower}, keyVizSeriesWrites)
369+
require.Len(t, merged.Rows, 1)
370+
require.Equal(t, []uint64{30, 40}, merged.Rows[0].Values)
371+
require.Nil(t, merged.Rows[0].Conflicts, "a cleanly merged write row must leave Conflicts nil for omitempty")
372+
require.False(t, merged.Rows[0].Conflict)
373+
}
374+
375+
// TestMergeKeyVizMatricesReadsLeaveConflictsNil pins that the read
376+
// merge path never allocates Conflicts — reads are independent local
377+
// serves that sum and can never conflict, so the per-cell slice stays
378+
// nil and is omitted from the wire.
379+
func TestMergeKeyVizMatricesReadsLeaveConflictsNil(t *testing.T) {
380+
t.Parallel()
381+
col := []int64{1_700_000_000_000}
382+
a := KeyVizMatrix{
383+
ColumnUnixMs: col,
384+
Series: keyVizSeriesReads,
385+
Rows: []KeyVizRow{{BucketID: "route:1", Values: []uint64{10}}},
386+
}
387+
b := KeyVizMatrix{
388+
ColumnUnixMs: col,
389+
Series: keyVizSeriesReads,
390+
Rows: []KeyVizRow{{BucketID: "route:1", Values: []uint64{25}}},
391+
}
392+
merged := mergeKeyVizMatrices([]KeyVizMatrix{a, b}, keyVizSeriesReads)
393+
require.Len(t, merged.Rows, 1)
394+
require.Nil(t, merged.Rows[0].Conflicts, "reads never conflict — Conflicts stays nil")
395+
require.False(t, merged.Rows[0].Conflict)
396+
}
397+
310398
// TestMergeKeyVizMatricesWritesMaxLeadershipFlip pins §4.2 under a
311399
// mid-window flip: two nodes report non-zero, disagreeing values
312400
// for the same cell. The merge keeps the larger value and raises

internal/admin/keyviz_handler.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,13 @@ type KeyVizMatrix struct {
7676
// merge disagreement. For writes the predicate fires when ≥2 sources
7777
// reported different non-zero values for the SAME
7878
// (bucket, raft_group_id, leader_term, column) tuple — a Raft
79-
// invariant violation (at most one leader per term per group), so
80-
// the SPA hatches the row. Phase 2-C+ PR-3c upgraded the merge from
79+
// invariant violation (at most one leader per term per group). It is
80+
// the OR of Conflicts[] and stays on the wire as the coarse signal an
81+
// older SPA hatches the whole row from; newer clients prefer the
82+
// per-cell Conflicts slice. Phase 2-C+ PR-3c upgraded the merge from
8183
// the Phase 2-C row-level §4.2 max-merge to the canonical §9.1
82-
// per-cell (group, term)-keyed dedupe + sum-across-terms; the
83-
// row-level Conflict flag stays as the coarse SPA signal and a
84-
// per-cell `Conflicts []bool` is deferred to a future wire-format
85-
// extension.
84+
// per-cell (group, term)-keyed dedupe + sum-across-terms; PR-3d
85+
// surfaces that per-cell conflict bit on the wire via Conflicts[].
8686
type KeyVizRow struct {
8787
BucketID string `json:"bucket_id"`
8888
Start []byte `json:"start"`
@@ -93,6 +93,15 @@ type KeyVizRow struct {
9393
RouteCount uint64 `json:"route_count"`
9494
Values []uint64 `json:"values"`
9595
Conflict bool `json:"conflict,omitempty"`
96+
// Conflicts[j] is true when fan-out merge saw ≥2 sources report
97+
// different non-zero values for the same
98+
// (bucket, raft_group_id, leader_term, column j) tuple, so the SPA
99+
// can hatch the individual cell rather than the whole row. Allocated
100+
// lazily and only on the write path: nil whenever the row had no
101+
// conflict (single-node, no fan-out, legacy server, or a cleanly
102+
// merged row) so omitempty keeps it off the wire; otherwise
103+
// len == len(Values). Conflict is the OR of this slice.
104+
Conflicts []bool `json:"conflicts,omitempty"`
96105
// RaftGroupIDs[j] and LeaderTerms[j] carry the route's Raft
97106
// identity at the time column j was flushed (parallel to
98107
// Values[]). Phase 2-C+ fan-out uses

web/admin/src/api/client.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -346,12 +346,17 @@ export interface KeyVizRow {
346346
route_ids_truncated?: boolean;
347347
route_count: number;
348348
values: number[];
349-
// Phase 2-C row-level conflict flag (always present on the wire,
350-
// defaults to false). True when ≥2 nodes reported a non-zero
351-
// value for the same cell — typically a leadership flip mid-
352-
// window. Per design 4.2 / PR-1, this is a row-level signal; it
353-
// moves to per-cell when the proto extension lands in 2-C+.
349+
// Row-level conflict flag — the OR of conflicts[]. True when ≥2
350+
// nodes reported a different non-zero value for the same cell,
351+
// typically a leadership flip mid-window. Kept on the wire so an
352+
// older client without per-cell support still hatches the row.
354353
conflict?: boolean;
354+
// Per-cell conflict flags (Phase 2-C+ PR-3d), parallel to values[].
355+
// conflicts[j] is true when column j saw a within-term merge
356+
// disagreement. Absent on the single-node / no-fan-out path and from
357+
// legacy servers; when absent the SPA falls back to the row-level
358+
// conflict flag.
359+
conflicts?: boolean[];
355360
}
356361

357362
// Per-node entry in the KeyVizMatrix.fanout block. OK=true means

0 commit comments

Comments
 (0)