detached efce2f7

perf(history): recycle mutable tail storage and style sets across seals

sealTail dropped tailCells/tail/bounds/IDs and the per-page style maps, so
every chunk reallocated up to 32K 104-byte cells (85% of build bytes).
Nothing outside History can alias that storage: sealed chunks and the
cached tail are compact copies built by newHistoryChunks (bounds and IDs
are copied), SnapshotView deep-copies its tail, Range/Row return decoded
copies, and tests-only code touches h.tail. Seal now keeps the backing
arrays at len 0 (clearing cells so payload strings are not pinned) and
releases oversized wide-row storage as before.

Also reuse tailPageStyles/tailPagePayloads via clear() (dropped if they
grew past 1024 entries), reuse one lastStyleRow scratch map across tail
chunks, and skip Canonical/map writes for runs of identical raw styles.
Tests pin that views, snapshot tails and Range rows taken before seals stay
unchanged and that logical-byte accounting is unaffected.
commit
efce2f7c3dde66be72170cb9bebddf32da6e6642
author
brice · · 2026-10-02 09:31 UTC
parent
96f4900
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +263 -14
M history.go +69 -14
@@ -39,6 +39,7 @@ type History struct {
3939 styleScratch map[renderer.Style]struct{}
4040 payloadScratch map[renderer.CellPayload]struct{}
4141 tailPagePayloads map[renderer.CellPayload]struct{}
42+ styleRowScratch map[renderer.Style]int
4243 rows int
4344 cells int
4445 logicalBytes uint64
@@ -62,6 +63,16 @@ type HistoryChunk struct {
6263 }
6364
6465 func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) []*HistoryChunk {
66+ return newHistoryChunksScratch(rows, bounds, rowIDs, nil)
67+}
68+
69+// maxRetainedStyleScratch bounds the style-set capacity kept between uses so
70+// one pathological high-cardinality page cannot pin a large map forever.
71+const maxRetainedStyleScratch = 1024
72+
73+// newHistoryChunksScratch is newHistoryChunks with an optional caller-owned
74+// map reused (cleared) across chunks instead of allocating one per chunk.
75+func newHistoryChunksScratch(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID, lastStyleRow map[renderer.Style]int) []*HistoryChunk {
6576 bounds = growBounds(bounds, len(rows))
6677 rowIDs = growRowIDs(rowIDs, len(rows))
6778 var chunks []*HistoryChunk
@@ -73,12 +84,25 @@ func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID
7384 end++
7485 }
7586 frame := renderer.NewFrame(width, end-start)
76- lastStyleRow := make(map[renderer.Style]int)
87+ if lastStyleRow == nil {
88+ lastStyleRow = make(map[renderer.Style]int)
89+ } else {
90+ clear(lastStyleRow)
91+ }
92+ defaultStyle := renderer.DefaultStyle()
7793 for i := start; i < end; i++ {
7894 frame.WriteRow(i-start, 0, rows[i])
79- for _, cell := range rows[i] {
95+ // A run of identical raw styles canonicalizes identically and is
96+ // already recorded for this row, so only the first cell of a run
97+ // pays for Canonical and the map write.
98+ var previous renderer.Style
99+ for j, cell := range rows[i] {
100+ if j > 0 && cell.Style == previous {
101+ continue
102+ }
103+ previous = cell.Style
80104 style := cell.Style.Canonical()
81- if style != renderer.DefaultStyle() {
105+ if style != defaultStyle {
82106 lastStyleRow[style] = i - start
83107 }
84108 }
@@ -300,8 +324,8 @@ func (h *History) recordTailRowStyles(width int) {
300324 if h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) {
301325 h.tailPageWidth = width
302326 h.tailPageRows = 0
303- h.tailPageStyles = map[renderer.Style]struct{}{renderer.DefaultStyle(): {}}
304- h.tailPagePayloads = nil
327+ h.resetTailPageSets()
328+ h.tailPageStyles[renderer.DefaultStyle()] = struct{}{}
305329 }
306330 for p := range h.payloadScratch {
307331 if h.tailPagePayloads == nil {
@@ -315,13 +339,28 @@ func (h *History) recordTailRowStyles(width int) {
315339 h.tailPageRows++
316340 }
317341
342+// resetTailPageSets empties the per-page style and payload sets, keeping their
343+// storage for the next page unless a pathological page grew them past
344+// maxRetainedStyleScratch. The style set is always non-nil afterwards.
345+func (h *History) resetTailPageSets() {
346+ if h.tailPageStyles == nil || len(h.tailPageStyles) > maxRetainedStyleScratch {
347+ h.tailPageStyles = make(map[renderer.Style]struct{})
348+ } else {
349+ clear(h.tailPageStyles)
350+ }
351+ if len(h.tailPagePayloads) > maxRetainedStyleScratch {
352+ h.tailPagePayloads = nil
353+ } else {
354+ clear(h.tailPagePayloads)
355+ }
356+}
357+
318358 func (h *History) rebuildTailAccounting() {
319359 old := h.tailBytes
320360 h.tailBytes = 0
321361 h.tailPageWidth = 0
322362 h.tailPageRows = 0
323- h.tailPageStyles = nil
324- h.tailPagePayloads = nil
363+ h.resetTailPageSets()
325364 for _, row := range h.tail {
326365 h.prepareRowStyles(row)
327366 delta := h.tailAppendDelta(len(row))
@@ -496,16 +535,26 @@ func (h *History) sealTail() {
496535 chunks := h.tailViewChunks()
497536 h.chunks = append(h.chunks, chunks...)
498537 h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks)
499- h.tail = nil
500538 h.cachedTail = nil
501- h.tailCells = nil
502- h.tailBounds = nil
503- h.tailIDs = nil
539+ // The sealed chunks hold compact copies (newHistoryChunks and the bounds and
540+ // ID slices are copied), and no view, snapshot or callback ever receives
541+ // h.tail rows or h.tailCells. The mutable-tail storage is therefore private
542+ // and can be recycled for the next chunk instead of reallocated. Cells are
543+ // cleared so recycled storage does not pin payload strings. Oversized
544+ // storage (wide rows) is released as before.
545+ clear(h.tail)
546+ h.tail = h.tail[:0]
547+ if cap(h.tailCells) > maxTailPreallocCells {
548+ h.tailCells = nil
549+ } else {
550+ clear(h.tailCells)
551+ h.tailCells = h.tailCells[:0]
552+ }
553+ h.tailBounds = h.tailBounds[:0]
554+ h.tailIDs = h.tailIDs[:0]
504555 h.tailBytes = 0
505556 h.tailPageWidth = 0
506557 h.tailPageRows = 0
507- h.tailPageStyles = nil
508- h.tailPagePayloads = nil
509558 }
510559
511560 // evictFor discards oldest rows until row can fit both retention budgets.
@@ -569,7 +618,13 @@ func (h *History) SealAndView() HistoryView {
569618
570619 func (h *History) tailViewChunks() []*HistoryChunk {
571620 if h.cachedTail == nil && len(h.tail) > 0 {
572- h.cachedTail = newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)
621+ if len(h.styleRowScratch) > maxRetainedStyleScratch {
622+ h.styleRowScratch = nil
623+ }
624+ if h.styleRowScratch == nil {
625+ h.styleRowScratch = make(map[renderer.Style]int)
626+ }
627+ h.cachedTail = newHistoryChunksScratch(h.tail, h.tailBounds, h.tailIDs, h.styleRowScratch)
573628 }
574629 return h.cachedTail
575630 }
@@ -39,6 +39,7 @@ type History struct {
39 styleScratch map[renderer.Style]struct{}39 styleScratch map[renderer.Style]struct{}
40 payloadScratch map[renderer.CellPayload]struct{}40 payloadScratch map[renderer.CellPayload]struct{}
41 tailPagePayloads map[renderer.CellPayload]struct{}41 tailPagePayloads map[renderer.CellPayload]struct{}
42+ styleRowScratch map[renderer.Style]int
42 rows int43 rows int
43 cells int44 cells int
44 logicalBytes uint6445 logicalBytes uint64
@@ -62,6 +63,16 @@ type HistoryChunk struct {
62 }63 }
63 64
64 func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) []*HistoryChunk {65 func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) []*HistoryChunk {
66+ return newHistoryChunksScratch(rows, bounds, rowIDs, nil)
67+}
68+
69+// maxRetainedStyleScratch bounds the style-set capacity kept between uses so
70+// one pathological high-cardinality page cannot pin a large map forever.
71+const maxRetainedStyleScratch = 1024
72+
73+// newHistoryChunksScratch is newHistoryChunks with an optional caller-owned
74+// map reused (cleared) across chunks instead of allocating one per chunk.
75+func newHistoryChunksScratch(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID, lastStyleRow map[renderer.Style]int) []*HistoryChunk {
65 bounds = growBounds(bounds, len(rows))76 bounds = growBounds(bounds, len(rows))
66 rowIDs = growRowIDs(rowIDs, len(rows))77 rowIDs = growRowIDs(rowIDs, len(rows))
67 var chunks []*HistoryChunk78 var chunks []*HistoryChunk
@@ -73,12 +84,25 @@ func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID
73 end++84 end++
74 }85 }
75 frame := renderer.NewFrame(width, end-start)86 frame := renderer.NewFrame(width, end-start)
76- lastStyleRow := make(map[renderer.Style]int)87+ if lastStyleRow == nil {
88+ lastStyleRow = make(map[renderer.Style]int)
89+ } else {
90+ clear(lastStyleRow)
91+ }
92+ defaultStyle := renderer.DefaultStyle()
77 for i := start; i < end; i++ {93 for i := start; i < end; i++ {
78 frame.WriteRow(i-start, 0, rows[i])94 frame.WriteRow(i-start, 0, rows[i])
79- for _, cell := range rows[i] {95+ // A run of identical raw styles canonicalizes identically and is
96+ // already recorded for this row, so only the first cell of a run
97+ // pays for Canonical and the map write.
98+ var previous renderer.Style
99+ for j, cell := range rows[i] {
100+ if j > 0 && cell.Style == previous {
101+ continue
102+ }
103+ previous = cell.Style
80 style := cell.Style.Canonical()104 style := cell.Style.Canonical()
81- if style != renderer.DefaultStyle() {105+ if style != defaultStyle {
82 lastStyleRow[style] = i - start106 lastStyleRow[style] = i - start
83 }107 }
84 }108 }
@@ -300,8 +324,8 @@ func (h *History) recordTailRowStyles(width int) {
300 if h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) {324 if h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) {
301 h.tailPageWidth = width325 h.tailPageWidth = width
302 h.tailPageRows = 0326 h.tailPageRows = 0
303- h.tailPageStyles = map[renderer.Style]struct{}{renderer.DefaultStyle(): {}}327+ h.resetTailPageSets()
304- h.tailPagePayloads = nil328+ h.tailPageStyles[renderer.DefaultStyle()] = struct{}{}
305 }329 }
306 for p := range h.payloadScratch {330 for p := range h.payloadScratch {
307 if h.tailPagePayloads == nil {331 if h.tailPagePayloads == nil {
@@ -315,13 +339,28 @@ func (h *History) recordTailRowStyles(width int) {
315 h.tailPageRows++339 h.tailPageRows++
316 }340 }
317 341
342+// resetTailPageSets empties the per-page style and payload sets, keeping their
343+// storage for the next page unless a pathological page grew them past
344+// maxRetainedStyleScratch. The style set is always non-nil afterwards.
345+func (h *History) resetTailPageSets() {
346+ if h.tailPageStyles == nil || len(h.tailPageStyles) > maxRetainedStyleScratch {
347+ h.tailPageStyles = make(map[renderer.Style]struct{})
348+ } else {
349+ clear(h.tailPageStyles)
350+ }
351+ if len(h.tailPagePayloads) > maxRetainedStyleScratch {
352+ h.tailPagePayloads = nil
353+ } else {
354+ clear(h.tailPagePayloads)
355+ }
356+}
357+
318 func (h *History) rebuildTailAccounting() {358 func (h *History) rebuildTailAccounting() {
319 old := h.tailBytes359 old := h.tailBytes
320 h.tailBytes = 0360 h.tailBytes = 0
321 h.tailPageWidth = 0361 h.tailPageWidth = 0
322 h.tailPageRows = 0362 h.tailPageRows = 0
323- h.tailPageStyles = nil363+ h.resetTailPageSets()
324- h.tailPagePayloads = nil
325 for _, row := range h.tail {364 for _, row := range h.tail {
326 h.prepareRowStyles(row)365 h.prepareRowStyles(row)
327 delta := h.tailAppendDelta(len(row))366 delta := h.tailAppendDelta(len(row))
@@ -496,16 +535,26 @@ func (h *History) sealTail() {
496 chunks := h.tailViewChunks()535 chunks := h.tailViewChunks()
497 h.chunks = append(h.chunks, chunks...)536 h.chunks = append(h.chunks, chunks...)
498 h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks)537 h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks)
499- h.tail = nil
500 h.cachedTail = nil538 h.cachedTail = nil
501- h.tailCells = nil539+ // The sealed chunks hold compact copies (newHistoryChunks and the bounds and
502- h.tailBounds = nil540+ // ID slices are copied), and no view, snapshot or callback ever receives
503- h.tailIDs = nil541+ // h.tail rows or h.tailCells. The mutable-tail storage is therefore private
542+ // and can be recycled for the next chunk instead of reallocated. Cells are
543+ // cleared so recycled storage does not pin payload strings. Oversized
544+ // storage (wide rows) is released as before.
545+ clear(h.tail)
546+ h.tail = h.tail[:0]
547+ if cap(h.tailCells) > maxTailPreallocCells {
548+ h.tailCells = nil
549+ } else {
550+ clear(h.tailCells)
551+ h.tailCells = h.tailCells[:0]
552+ }
553+ h.tailBounds = h.tailBounds[:0]
554+ h.tailIDs = h.tailIDs[:0]
504 h.tailBytes = 0555 h.tailBytes = 0
505 h.tailPageWidth = 0556 h.tailPageWidth = 0
506 h.tailPageRows = 0557 h.tailPageRows = 0
507- h.tailPageStyles = nil
508- h.tailPagePayloads = nil
509 }558 }
510 559
511 // evictFor discards oldest rows until row can fit both retention budgets.560 // evictFor discards oldest rows until row can fit both retention budgets.
@@ -569,7 +618,13 @@ func (h *History) SealAndView() HistoryView {
569 618
570 func (h *History) tailViewChunks() []*HistoryChunk {619 func (h *History) tailViewChunks() []*HistoryChunk {
571 if h.cachedTail == nil && len(h.tail) > 0 {620 if h.cachedTail == nil && len(h.tail) > 0 {
572- h.cachedTail = newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)621+ if len(h.styleRowScratch) > maxRetainedStyleScratch {
622+ h.styleRowScratch = nil
623+ }
624+ if h.styleRowScratch == nil {
625+ h.styleRowScratch = make(map[renderer.Style]int)
626+ }
627+ h.cachedTail = newHistoryChunksScratch(h.tail, h.tailBounds, h.tailIDs, h.styleRowScratch)
573 }628 }
574 return h.cachedTail629 return h.cachedTail
575 }630 }
A history_tail_reuse_test.go +194 -0
@@ -0,0 +1,194 @@
1+package vt
2+
3+import (
4+ "fmt"
5+ "math/rand"
6+ "testing"
7+
8+ renderer "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func styledRow(text string, fg int) []renderer.Cell {
13+ row := historyRow(text)
14+ for i := range row {
15+ row[i].Style = renderer.Style{Bold: true, Foreground: fg}
16+ }
17+ return row
18+}
19+
20+func rangeTexts(t *testing.T, v HistoryView) []string {
21+ t.Helper()
22+ var out []string
23+ require.NoError(t, v.Range(func(row []renderer.Cell) bool {
24+ out = append(out, rowText(row))
25+ return true
26+ }))
27+ return out
28+}
29+
30+// Sealing recycles the private tail storage. No previously handed-out view,
31+// snapshot tail or Range row may observe later appends or seals.
32+func TestHistoryTailStorageReuseKeepsViewsStable(t *testing.T) {
33+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 4})
34+ appendRow := func(i int) {
35+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%5+1), LineBound{End: 4}))
36+ }
37+ for i := range 2 {
38+ appendRow(i)
39+ }
40+ partial := h.View()
41+ snap := h.SnapshotView()
42+ var ranged [][]renderer.Cell
43+ require.NoError(t, partial.Range(func(row []renderer.Cell) bool { ranged = append(ranged, row); return true }))
44+
45+ for i := 2; i < 14; i++ { // seals three times, reusing storage each time
46+ appendRow(i)
47+ }
48+ sealedView := h.SealAndView()
49+ appendRow(14)
50+ appendRow(15)
51+
52+ require.Equal(t, []string{"r000", "r001"}, rangeTexts(t, partial))
53+ require.Equal(t, []string{"r000", "r001"}, rangeTexts(t, snap.Tail()))
54+ require.Equal(t, "r000", rowText(ranged[0]))
55+ require.Equal(t, "r001", rowText(ranged[1]))
56+ require.Equal(t, 14, sealedView.Len())
57+ texts := rangeTexts(t, sealedView)
58+ for i, got := range texts {
59+ require.Equal(t, fmt.Sprintf("r%03d", i), got)
60+ }
61+ live := rangeTexts(t, h.View())
62+ require.Len(t, live, 16)
63+ require.Equal(t, "r015", live[15])
64+ for _, c := range h.chunks {
65+ require.NoError(t, c.CheckInvariants())
66+ }
67+}
68+
69+func TestHistoryTailStorageIsRecycledAcrossSeals(t *testing.T) {
70+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 4})
71+ for i := range 4 {
72+ require.NoError(t, h.Append(historyRow(fmt.Sprintf("r%03d", i)), LineBound{End: 4}))
73+ }
74+ require.Empty(t, h.tail)
75+ require.Zero(t, len(h.tailCells))
76+ require.NotZero(t, cap(h.tailCells), "seal must keep tail storage for the next chunk")
77+ first := h.tailCells[:1][0:1]
78+ for i := 4; i < 8; i++ {
79+ require.NoError(t, h.Append(historyRow(fmt.Sprintf("r%03d", i)), LineBound{End: 4}))
80+ }
81+ require.Same(t, &first[0], &h.tailCells[:1][0], "storage reallocated across seals")
82+ // A reused backing array must not pin payload strings of sealed rows.
83+ payload, err := renderer.NewCellPayload("é\u0301", "https://example.test")
84+ require.NoError(t, err)
85+ row := historyRow("abcd")
86+ row[0].Payload = payload
87+ for range 4 {
88+ require.NoError(t, h.Append(row, LineBound{End: 4}))
89+ }
90+ for _, c := range h.tailCells[:cap(h.tailCells)] {
91+ require.True(t, c.Payload.Empty())
92+ }
93+ require.NoError(t, h.chunks[len(h.chunks)-1].CheckInvariants())
94+}
95+
96+// Accounting after style/payload set reuse must match a history that has never
97+// sealed anything.
98+func TestHistoryLogicalBytesIndependentOfSetReuse(t *testing.T) {
99+ payload, err := renderer.NewCellPayload("x", "https://example.test")
100+ require.NoError(t, err)
101+ rows := make([][]renderer.Cell, 40)
102+ for i := range rows {
103+ rows[i] = styledRow("abcd", i%7+1)
104+ if i%3 == 0 {
105+ rows[i][1].Payload = payload
106+ }
107+ }
108+ reused := NewHistory(HistoryConfig{MaxRows: 100, MaxBytes: 1 << 20, ChunkRows: 5})
109+ whole := NewHistory(HistoryConfig{MaxRows: 100, MaxBytes: 1 << 20, ChunkRows: 100})
110+ for _, row := range rows {
111+ require.NoError(t, reused.Append(row, LineBound{End: 4}))
112+ require.NoError(t, whole.Append(row, LineBound{End: 4}))
113+ }
114+ // Re-encode each retained chunk from scratch and compare accounting.
115+ var fresh uint64
116+ for _, c := range reused.View().chunks {
117+ rebuilt := newHistoryChunks(c.rowsForTest(), c.bounds, c.rowIDs)
118+ fresh += historyChunksLogicalBytes(rebuilt)
119+ }
120+ require.Equal(t, fresh, reused.LogicalBytes())
121+ require.Equal(t, rangeTexts(t, whole.View()), rangeTexts(t, reused.View()))
122+}
123+
124+func (c *HistoryChunk) rowsForTest() [][]renderer.Cell {
125+ rows := make([][]renderer.Cell, c.len())
126+ for i := range rows {
127+ rows[i] = c.row(i)
128+ }
129+ return rows
130+}
131+
132+// rowIDSet must accept and reject exactly what a plain map would, including
133+// out-of-order IDs and duplicates across commits.
134+func TestRowIDSetMatchesMap(t *testing.T) {
135+ rng := rand.New(rand.NewSource(1))
136+ for iter := 0; iter < 5000; iter++ {
137+ var set rowIDSet
138+ ref := map[RowID]struct{}{}
139+ for commit := 0; commit < 1+rng.Intn(4); commit++ {
140+ n := rng.Intn(6)
141+ ids := make([]RowID, n)
142+ base := RowID(rng.Intn(30))
143+ for i := range ids {
144+ if rng.Intn(3) == 0 {
145+ ids[i] = RowID(1 + rng.Intn(30))
146+ } else {
147+ ids[i] = base + RowID(i) + 1
148+ }
149+ }
150+ if rng.Intn(4) == 0 && n > 1 {
151+ ids[0], ids[n-1] = ids[n-1], ids[0]
152+ }
153+ want := true
154+ seen := map[RowID]struct{}{}
155+ for _, id := range ids {
156+ if _, dup := ref[id]; dup {
157+ want = false
158+ }
159+ if _, dup := seen[id]; dup {
160+ want = false
161+ }
162+ seen[id] = struct{}{}
163+ }
164+ set.reserve(n)
165+ for _, id := range ids {
166+ set.collect(id)
167+ }
168+ require.Equal(t, want, set.commit(), "iter %d ids %v", iter, ids)
169+ if !want {
170+ break
171+ }
172+ for _, id := range ids {
173+ ref[id] = struct{}{}
174+ }
175+ }
176+ }
177+}
178+
179+func TestRestoreAcceptsOutOfOrderDistinctRowIDsAcrossBlobs(t *testing.T) {
180+ src := NewHistory(HistoryConfig{MaxRows: 16, ChunkRows: 2})
181+ for _, id := range []RowID{100, 7, 8, 9} {
182+ require.NoError(t, src.AppendWithID(historyRow("abcd"), LineBound{End: 4}, id))
183+ }
184+ sealed, tail, err := MarshalSealedHistory(src.SealAndView())
185+ require.NoError(t, err)
186+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, sealed, tail)
187+ require.NoError(t, err)
188+ require.Equal(t, 4, restored.Len())
189+ // Same IDs in two blobs are still rejected, in either order.
190+ _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, [][]byte{sealed[0], sealed[0]}, tail)
191+ require.Error(t, err)
192+ _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, [][]byte{sealed[1], sealed[0], sealed[1]}, tail)
193+ require.Error(t, err)
194+}
@@ -0,0 +1,194 @@
1+package vt
2+
3+import (
4+ "fmt"
5+ "math/rand"
6+ "testing"
7+
8+ renderer "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func styledRow(text string, fg int) []renderer.Cell {
13+ row := historyRow(text)
14+ for i := range row {
15+ row[i].Style = renderer.Style{Bold: true, Foreground: fg}
16+ }
17+ return row
18+}
19+
20+func rangeTexts(t *testing.T, v HistoryView) []string {
21+ t.Helper()
22+ var out []string
23+ require.NoError(t, v.Range(func(row []renderer.Cell) bool {
24+ out = append(out, rowText(row))
25+ return true
26+ }))
27+ return out
28+}
29+
30+// Sealing recycles the private tail storage. No previously handed-out view,
31+// snapshot tail or Range row may observe later appends or seals.
32+func TestHistoryTailStorageReuseKeepsViewsStable(t *testing.T) {
33+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 4})
34+ appendRow := func(i int) {
35+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%5+1), LineBound{End: 4}))
36+ }
37+ for i := range 2 {
38+ appendRow(i)
39+ }
40+ partial := h.View()
41+ snap := h.SnapshotView()
42+ var ranged [][]renderer.Cell
43+ require.NoError(t, partial.Range(func(row []renderer.Cell) bool { ranged = append(ranged, row); return true }))
44+
45+ for i := 2; i < 14; i++ { // seals three times, reusing storage each time
46+ appendRow(i)
47+ }
48+ sealedView := h.SealAndView()
49+ appendRow(14)
50+ appendRow(15)
51+
52+ require.Equal(t, []string{"r000", "r001"}, rangeTexts(t, partial))
53+ require.Equal(t, []string{"r000", "r001"}, rangeTexts(t, snap.Tail()))
54+ require.Equal(t, "r000", rowText(ranged[0]))
55+ require.Equal(t, "r001", rowText(ranged[1]))
56+ require.Equal(t, 14, sealedView.Len())
57+ texts := rangeTexts(t, sealedView)
58+ for i, got := range texts {
59+ require.Equal(t, fmt.Sprintf("r%03d", i), got)
60+ }
61+ live := rangeTexts(t, h.View())
62+ require.Len(t, live, 16)
63+ require.Equal(t, "r015", live[15])
64+ for _, c := range h.chunks {
65+ require.NoError(t, c.CheckInvariants())
66+ }
67+}
68+
69+func TestHistoryTailStorageIsRecycledAcrossSeals(t *testing.T) {
70+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 4})
71+ for i := range 4 {
72+ require.NoError(t, h.Append(historyRow(fmt.Sprintf("r%03d", i)), LineBound{End: 4}))
73+ }
74+ require.Empty(t, h.tail)
75+ require.Zero(t, len(h.tailCells))
76+ require.NotZero(t, cap(h.tailCells), "seal must keep tail storage for the next chunk")
77+ first := h.tailCells[:1][0:1]
78+ for i := 4; i < 8; i++ {
79+ require.NoError(t, h.Append(historyRow(fmt.Sprintf("r%03d", i)), LineBound{End: 4}))
80+ }
81+ require.Same(t, &first[0], &h.tailCells[:1][0], "storage reallocated across seals")
82+ // A reused backing array must not pin payload strings of sealed rows.
83+ payload, err := renderer.NewCellPayload("é\u0301", "https://example.test")
84+ require.NoError(t, err)
85+ row := historyRow("abcd")
86+ row[0].Payload = payload
87+ for range 4 {
88+ require.NoError(t, h.Append(row, LineBound{End: 4}))
89+ }
90+ for _, c := range h.tailCells[:cap(h.tailCells)] {
91+ require.True(t, c.Payload.Empty())
92+ }
93+ require.NoError(t, h.chunks[len(h.chunks)-1].CheckInvariants())
94+}
95+
96+// Accounting after style/payload set reuse must match a history that has never
97+// sealed anything.
98+func TestHistoryLogicalBytesIndependentOfSetReuse(t *testing.T) {
99+ payload, err := renderer.NewCellPayload("x", "https://example.test")
100+ require.NoError(t, err)
101+ rows := make([][]renderer.Cell, 40)
102+ for i := range rows {
103+ rows[i] = styledRow("abcd", i%7+1)
104+ if i%3 == 0 {
105+ rows[i][1].Payload = payload
106+ }
107+ }
108+ reused := NewHistory(HistoryConfig{MaxRows: 100, MaxBytes: 1 << 20, ChunkRows: 5})
109+ whole := NewHistory(HistoryConfig{MaxRows: 100, MaxBytes: 1 << 20, ChunkRows: 100})
110+ for _, row := range rows {
111+ require.NoError(t, reused.Append(row, LineBound{End: 4}))
112+ require.NoError(t, whole.Append(row, LineBound{End: 4}))
113+ }
114+ // Re-encode each retained chunk from scratch and compare accounting.
115+ var fresh uint64
116+ for _, c := range reused.View().chunks {
117+ rebuilt := newHistoryChunks(c.rowsForTest(), c.bounds, c.rowIDs)
118+ fresh += historyChunksLogicalBytes(rebuilt)
119+ }
120+ require.Equal(t, fresh, reused.LogicalBytes())
121+ require.Equal(t, rangeTexts(t, whole.View()), rangeTexts(t, reused.View()))
122+}
123+
124+func (c *HistoryChunk) rowsForTest() [][]renderer.Cell {
125+ rows := make([][]renderer.Cell, c.len())
126+ for i := range rows {
127+ rows[i] = c.row(i)
128+ }
129+ return rows
130+}
131+
132+// rowIDSet must accept and reject exactly what a plain map would, including
133+// out-of-order IDs and duplicates across commits.
134+func TestRowIDSetMatchesMap(t *testing.T) {
135+ rng := rand.New(rand.NewSource(1))
136+ for iter := 0; iter < 5000; iter++ {
137+ var set rowIDSet
138+ ref := map[RowID]struct{}{}
139+ for commit := 0; commit < 1+rng.Intn(4); commit++ {
140+ n := rng.Intn(6)
141+ ids := make([]RowID, n)
142+ base := RowID(rng.Intn(30))
143+ for i := range ids {
144+ if rng.Intn(3) == 0 {
145+ ids[i] = RowID(1 + rng.Intn(30))
146+ } else {
147+ ids[i] = base + RowID(i) + 1
148+ }
149+ }
150+ if rng.Intn(4) == 0 && n > 1 {
151+ ids[0], ids[n-1] = ids[n-1], ids[0]
152+ }
153+ want := true
154+ seen := map[RowID]struct{}{}
155+ for _, id := range ids {
156+ if _, dup := ref[id]; dup {
157+ want = false
158+ }
159+ if _, dup := seen[id]; dup {
160+ want = false
161+ }
162+ seen[id] = struct{}{}
163+ }
164+ set.reserve(n)
165+ for _, id := range ids {
166+ set.collect(id)
167+ }
168+ require.Equal(t, want, set.commit(), "iter %d ids %v", iter, ids)
169+ if !want {
170+ break
171+ }
172+ for _, id := range ids {
173+ ref[id] = struct{}{}
174+ }
175+ }
176+ }
177+}
178+
179+func TestRestoreAcceptsOutOfOrderDistinctRowIDsAcrossBlobs(t *testing.T) {
180+ src := NewHistory(HistoryConfig{MaxRows: 16, ChunkRows: 2})
181+ for _, id := range []RowID{100, 7, 8, 9} {
182+ require.NoError(t, src.AppendWithID(historyRow("abcd"), LineBound{End: 4}, id))
183+ }
184+ sealed, tail, err := MarshalSealedHistory(src.SealAndView())
185+ require.NoError(t, err)
186+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, sealed, tail)
187+ require.NoError(t, err)
188+ require.Equal(t, 4, restored.Len())
189+ // Same IDs in two blobs are still rejected, in either order.
190+ _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, [][]byte{sealed[0], sealed[0]}, tail)
191+ require.Error(t, err)
192+ _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, [][]byte{sealed[1], sealed[0], sealed[1]}, tail)
193+ require.Error(t, err)
194+}

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 2

Tags 13

↑↓ moveenter openesc close