detached 1fa07cc

fix(vt): accept wide history rows within budgets

commit
1fa07cc774c4eb18406c01a9d05314147c763d1a
author
brice · · 2026-07-21 18:48 UTC
committer
brice · · 2026-07-21 18:50 UTC
parent
8e5d334
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +72 -16
M pkg/vt/benchmark_test.go +23 -3
@@ -8,9 +8,10 @@ import (
88 )
99
1010 var (
11- benchmarkHistoryViewSink HistoryView
12- benchmarkHistoryBlobSink []byte
13- benchmarkHistoryBlobsSink [][]byte
11+ benchmarkHistoryViewSink HistoryView
12+ benchmarkHistorySnapshotSink HistorySnapshotView
13+ benchmarkHistoryBlobSink []byte
14+ benchmarkHistoryBlobsSink [][]byte
1415 )
1516
1617 // TestHistoryViewPartialTailAllocationBounded guards the capture property used
@@ -75,6 +76,25 @@ func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) {
7576 b.ReportMetric(float64(10_000%256), "tailrows/view")
7677 }
7778
79+func BenchmarkHistorySnapshotView10KRowsPartialTail(b *testing.B) {
80+ history := benchmarkHistory(b, 10_000, 120, 256)
81+ if got, want := len(history.tail), 16; got != want {
82+ b.Fatalf("partial tail rows = %d, want %d", got, want)
83+ }
84+ sealed := history.View().Chunk(0)
85+
86+ b.ReportAllocs()
87+ b.ResetTimer()
88+ for b.Loop() {
89+ benchmarkHistorySnapshotSink = history.SnapshotView()
90+ }
91+ b.StopTimer()
92+ if got := benchmarkHistorySnapshotSink; got.Chunk(0) != sealed || got.Tail().Len() != 16 {
93+ b.Fatal("snapshot did not preserve chunk identity and tail rows")
94+ }
95+ b.ReportMetric(float64(benchmarkHistorySnapshotSink.ChunkCount()), "sealedchunks/view")
96+}
97+
7898 func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {
7999 history := benchmarkHistory(b, 256, 120, 256)
80100 chunk := history.SealAndView().Chunk(0)
@@ -8,9 +8,10 @@ import (
8 )8 )
9 9
10 var (10 var (
11- benchmarkHistoryViewSink HistoryView11+ benchmarkHistoryViewSink HistoryView
12- benchmarkHistoryBlobSink []byte12+ benchmarkHistorySnapshotSink HistorySnapshotView
13- benchmarkHistoryBlobsSink [][]byte13+ benchmarkHistoryBlobSink []byte
14+ benchmarkHistoryBlobsSink [][]byte
14 )15 )
15 16
16 // TestHistoryViewPartialTailAllocationBounded guards the capture property used17 // TestHistoryViewPartialTailAllocationBounded guards the capture property used
@@ -75,6 +76,25 @@ func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) {
75 b.ReportMetric(float64(10_000%256), "tailrows/view")76 b.ReportMetric(float64(10_000%256), "tailrows/view")
76 }77 }
77 78
79+func BenchmarkHistorySnapshotView10KRowsPartialTail(b *testing.B) {
80+ history := benchmarkHistory(b, 10_000, 120, 256)
81+ if got, want := len(history.tail), 16; got != want {
82+ b.Fatalf("partial tail rows = %d, want %d", got, want)
83+ }
84+ sealed := history.View().Chunk(0)
85+
86+ b.ReportAllocs()
87+ b.ResetTimer()
88+ for b.Loop() {
89+ benchmarkHistorySnapshotSink = history.SnapshotView()
90+ }
91+ b.StopTimer()
92+ if got := benchmarkHistorySnapshotSink; got.Chunk(0) != sealed || got.Tail().Len() != 16 {
93+ b.Fatal("snapshot did not preserve chunk identity and tail rows")
94+ }
95+ b.ReportMetric(float64(benchmarkHistorySnapshotSink.ChunkCount()), "sealedchunks/view")
96+}
97+
78 func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {98 func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {
79 history := benchmarkHistory(b, 256, 120, 256)99 history := benchmarkHistory(b, 256, 120, 256)
80 chunk := history.SealAndView().Chunk(0)100 chunk := history.SealAndView().Chunk(0)
M pkg/vt/history.go +15 -2
@@ -88,13 +88,15 @@ func (h *History) Append(row []renderer.Cell) error {
8888 if len(row) > h.maxCells {
8989 return ErrHistoryRowTooWide
9090 }
91+ // Make room before adding so Cells cannot overflow even when the default
92+ // capacity is MaxInt.
93+ h.evictFor(len(row))
9194 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
9295 h.rows++
9396 h.cells += len(row)
9497 if len(h.tail) == h.chunkRows {
9598 h.sealTail()
9699 }
97- h.evict()
98100 return nil
99101 }
100102
@@ -107,7 +109,18 @@ func (h *History) sealTail() {
107109 }
108110
109111 func (h *History) evict() {
110- for h.rows > h.maxRows || h.cells > h.maxCells {
112+ h.evictUntil(0, 0)
113+}
114+
115+// evictFor discards oldest rows until another row using cellCount cells can fit.
116+func (h *History) evictFor(cellCount int) {
117+ h.evictUntil(1, cellCount)
118+}
119+
120+// evictUntil makes room for rowCount rows and cellCount cells without
121+// overflowing either accounting total.
122+func (h *History) evictUntil(rowCount, cellCount int) {
123+ for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount {
111124 if len(h.chunks) > 0 {
112125 chunk := h.chunks[0]
113126 row := chunk.rows[0]
@@ -88,13 +88,15 @@ func (h *History) Append(row []renderer.Cell) error {
88 if len(row) > h.maxCells {88 if len(row) > h.maxCells {
89 return ErrHistoryRowTooWide89 return ErrHistoryRowTooWide
90 }90 }
91+ // Make room before adding so Cells cannot overflow even when the default
92+ // capacity is MaxInt.
93+ h.evictFor(len(row))
91 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))94 h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
92 h.rows++95 h.rows++
93 h.cells += len(row)96 h.cells += len(row)
94 if len(h.tail) == h.chunkRows {97 if len(h.tail) == h.chunkRows {
95 h.sealTail()98 h.sealTail()
96 }99 }
97- h.evict()
98 return nil100 return nil
99 }101 }
100 102
@@ -107,7 +109,18 @@ func (h *History) sealTail() {
107 }109 }
108 110
109 func (h *History) evict() {111 func (h *History) evict() {
110- for h.rows > h.maxRows || h.cells > h.maxCells {112+ h.evictUntil(0, 0)
113+}
114+
115+// evictFor discards oldest rows until another row using cellCount cells can fit.
116+func (h *History) evictFor(cellCount int) {
117+ h.evictUntil(1, cellCount)
118+}
119+
120+// evictUntil makes room for rowCount rows and cellCount cells without
121+// overflowing either accounting total.
122+func (h *History) evictUntil(rowCount, cellCount int) {
123+ for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount {
111 if len(h.chunks) > 0 {124 if len(h.chunks) > 0 {
112 chunk := h.chunks[0]125 chunk := h.chunks[0]
113 row := chunk.rows[0]126 row := chunk.rows[0]
M pkg/vt/history_codec.go +4 -4
@@ -46,7 +46,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
4646 for _, row := range chunk.rows {
4747 cellCount := uint64(len(row))
4848 cellBytes, ok := historyCellByteCount(cellCount)
49- if cellCount > maxHistoryRowCells || !ok ||
49+ if !ok ||
5050 !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
5151 !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
5252 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
@@ -193,7 +193,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
193193 for range rowCount {
194194 cellCount, ok := p.uint32()
195195 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
196- if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes ||
196+ if !ok || !validCellBytes ||
197197 uint64(cellCount) > uint64(len(p.data))/historyCellBytes ||
198198 !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
199199 !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
@@ -205,7 +205,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
205205 if populate {
206206 row = make([]renderer.Cell, cellCount)
207207 }
208- for i := uint32(0); i < cellCount; i++ {
208+ for i := range cellCount {
209209 cell, ok := p.cell()
210210 if !ok || !validHistoryCell(cell) {
211211 return HistoryView{}, historyDecodeStats{}, false
@@ -225,7 +225,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
225225 if len(p.data) != 0 {
226226 return HistoryView{}, historyDecodeStats{}, false
227227 }
228- return HistoryView{chunks: chunks, rows: int(stats.rows)}, stats, true
228+ return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true
229229 }
230230
231231 func historyCellByteCount(cellCount uint64) (uint64, bool) {
@@ -46,7 +46,7 @@ func MarshalHistory(view HistoryView) ([]byte, error) {
46 for _, row := range chunk.rows {46 for _, row := range chunk.rows {
47 cellCount := uint64(len(row))47 cellCount := uint64(len(row))
48 cellBytes, ok := historyCellByteCount(cellCount)48 cellBytes, ok := historyCellByteCount(cellCount)
49- if cellCount > maxHistoryRowCells || !ok ||49+ if !ok ||
50 !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||50 !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
51 !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||51 !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
52 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {52 !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
@@ -193,7 +193,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
193 for range rowCount {193 for range rowCount {
194 cellCount, ok := p.uint32()194 cellCount, ok := p.uint32()
195 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))195 cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
196- if !ok || uint64(cellCount) > maxHistoryRowCells || !validCellBytes ||196+ if !ok || !validCellBytes ||
197 uint64(cellCount) > uint64(len(p.data))/historyCellBytes ||197 uint64(cellCount) > uint64(len(p.data))/historyCellBytes ||
198 !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||198 !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
199 !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||199 !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
@@ -205,7 +205,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
205 if populate {205 if populate {
206 row = make([]renderer.Cell, cellCount)206 row = make([]renderer.Cell, cellCount)
207 }207 }
208- for i := uint32(0); i < cellCount; i++ {208+ for i := range cellCount {
209 cell, ok := p.cell()209 cell, ok := p.cell()
210 if !ok || !validHistoryCell(cell) {210 if !ok || !validHistoryCell(cell) {
211 return HistoryView{}, historyDecodeStats{}, false211 return HistoryView{}, historyDecodeStats{}, false
@@ -225,7 +225,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
225 if len(p.data) != 0 {225 if len(p.data) != 0 {
226 return HistoryView{}, historyDecodeStats{}, false226 return HistoryView{}, historyDecodeStats{}, false
227 }227 }
228- return HistoryView{chunks: chunks, rows: int(stats.rows)}, stats, true228+ return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells)}, stats, true
229 }229 }
230 230
231 func historyCellByteCount(cellCount uint64) (uint64, bool) {231 func historyCellByteCount(cellCount uint64) (uint64, bool) {
M pkg/vt/history_codec_test.go +28 -7
@@ -2,6 +2,8 @@ package vt
22
33 import (
44 "encoding/binary"
5+ "fmt"
6+ "math"
57 "testing"
68
79 "github.com/bnema/vev/pkg/renderer"
@@ -123,10 +125,7 @@ func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) {
123125 }
124126
125127 func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
126- const (
127- supportedRows = 12_000
128- supportedWidth = 160
129- )
128+ const supportedRows = 12_000
130129 tests := []struct {
131130 name string
132131 view HistoryView
@@ -138,9 +137,9 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
138137 data: historyPayloadWithDimensions(supportedRows+1, 0),
139138 },
140139 {
141- name: "too many cells in a row",
142- view: historyViewWithDimensions(1, supportedWidth+1),
143- data: historyPayloadWithDimensions(1, supportedWidth+1),
140+ name: "too many aggregate cells",
141+ view: historyViewWithDimensions(maxHistoryCells+1, 0),
142+ data: historyPayloadWithDimensions(maxHistoryRows+1, 0),
144143 },
145144 }
146145
@@ -156,6 +155,28 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
156155 }
157156 }
158157
158+func TestChunkCodecAcceptsWideRowsWithinAggregateBudget(t *testing.T) {
159+ for _, width := range []int{161, 293, math.MaxUint16} {
160+ t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
161+ view := historyViewWithDimensions(1, width)
162+ encoded, err := MarshalHistory(view)
163+ if err != nil {
164+ t.Fatalf("marshal width %d: %v", width, err)
165+ }
166+ decoded, err := UnmarshalHistory(encoded)
167+ if err != nil {
168+ t.Fatalf("unmarshal width %d: %v", width, err)
169+ }
170+ if got := len(decoded.Row(0)); got != width {
171+ t.Fatalf("decoded width = %d, want %d", got, width)
172+ }
173+ if stats, err := PreflightHistoryBlob(encoded); err != nil || stats.Cells != uint64(width) {
174+ t.Fatalf("preflight stats = %+v, err = %v", stats, err)
175+ }
176+ })
177+ }
178+}
179+
159180 func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
160181 tests := []struct {
161182 name string
@@ -2,6 +2,8 @@ package vt
2 2
3 import (3 import (
4 "encoding/binary"4 "encoding/binary"
5+ "fmt"
6+ "math"
5 "testing"7 "testing"
6 8
7 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
@@ -123,10 +125,7 @@ func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) {
123 }125 }
124 126
125 func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {127 func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
126- const (128+ const supportedRows = 12_000
127- supportedRows = 12_000
128- supportedWidth = 160
129- )
130 tests := []struct {129 tests := []struct {
131 name string130 name string
132 view HistoryView131 view HistoryView
@@ -138,9 +137,9 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
138 data: historyPayloadWithDimensions(supportedRows+1, 0),137 data: historyPayloadWithDimensions(supportedRows+1, 0),
139 },138 },
140 {139 {
141- name: "too many cells in a row",140+ name: "too many aggregate cells",
142- view: historyViewWithDimensions(1, supportedWidth+1),141+ view: historyViewWithDimensions(maxHistoryCells+1, 0),
143- data: historyPayloadWithDimensions(1, supportedWidth+1),142+ data: historyPayloadWithDimensions(maxHistoryRows+1, 0),
144 },143 },
145 }144 }
146 145
@@ -156,6 +155,28 @@ func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
156 }155 }
157 }156 }
158 157
158+func TestChunkCodecAcceptsWideRowsWithinAggregateBudget(t *testing.T) {
159+ for _, width := range []int{161, 293, math.MaxUint16} {
160+ t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
161+ view := historyViewWithDimensions(1, width)
162+ encoded, err := MarshalHistory(view)
163+ if err != nil {
164+ t.Fatalf("marshal width %d: %v", width, err)
165+ }
166+ decoded, err := UnmarshalHistory(encoded)
167+ if err != nil {
168+ t.Fatalf("unmarshal width %d: %v", width, err)
169+ }
170+ if got := len(decoded.Row(0)); got != width {
171+ t.Fatalf("decoded width = %d, want %d", got, width)
172+ }
173+ if stats, err := PreflightHistoryBlob(encoded); err != nil || stats.Cells != uint64(width) {
174+ t.Fatalf("preflight stats = %+v, err = %v", stats, err)
175+ }
176+ })
177+ }
178+}
179+
159 func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {180 func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
160 tests := []struct {181 tests := []struct {
161 name string182 name string
M pkg/vt/snapshot.go +2 -0
@@ -90,6 +90,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
9090 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {
9191 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
9292 }
93+ h.evictUntil(len(view.chunks[0].rows), view.Cells())
9394 h.chunks = append(h.chunks, view.chunks[0])
9495 h.rows += len(view.chunks[0].rows)
9596 h.cells += view.Cells()
@@ -99,6 +100,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
99100 return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
100101 }
101102 if len(view.chunks) == 1 {
103+ h.evictUntil(len(view.chunks[0].rows), view.Cells())
102104 h.tail = view.chunks[0].rows
103105 h.rows += len(h.tail)
104106 h.cells += view.Cells()
@@ -90,6 +90,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
90 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {90 if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 {
91 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)91 return nil, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
92 }92 }
93+ h.evictUntil(len(view.chunks[0].rows), view.Cells())
93 h.chunks = append(h.chunks, view.chunks[0])94 h.chunks = append(h.chunks, view.chunks[0])
94 h.rows += len(view.chunks[0].rows)95 h.rows += len(view.chunks[0].rows)
95 h.cells += view.Cells()96 h.cells += view.Cells()
@@ -99,6 +100,7 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
99 return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)100 return nil, fmt.Errorf("restore history tail: %w", errInvalidHistory)
100 }101 }
101 if len(view.chunks) == 1 {102 if len(view.chunks) == 1 {
103+ h.evictUntil(len(view.chunks[0].rows), view.Cells())
102 h.tail = view.chunks[0].rows104 h.tail = view.chunks[0].rows
103 h.rows += len(h.tail)105 h.rows += len(h.tail)
104 h.cells += view.Cells()106 h.cells += view.Cells()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close