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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +263 -14
M history.go +69 -14
| @@ -39,6 +39,7 @@ type History struct { | |||
| 39 | 39 | styleScratch map[renderer.Style]struct{} | |
| 40 | 40 | payloadScratch map[renderer.CellPayload]struct{} | |
| 41 | 41 | tailPagePayloads map[renderer.CellPayload]struct{} | |
| 42 | + | styleRowScratch map[renderer.Style]int | |
| 42 | 43 | rows int | |
| 43 | 44 | cells int | |
| 44 | 45 | logicalBytes uint64 | |
| @@ -62,6 +63,16 @@ type HistoryChunk struct { | |||
| 62 | 63 | } | |
| 63 | 64 | ||
| 64 | 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 | 76 | bounds = growBounds(bounds, len(rows)) | |
| 66 | 77 | rowIDs = growRowIDs(rowIDs, len(rows)) | |
| 67 | 78 | var chunks []*HistoryChunk | |
| @@ -73,12 +84,25 @@ func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID | |||
| 73 | 84 | end++ | |
| 74 | 85 | } | |
| 75 | 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 | 93 | for i := start; i < end; i++ { | |
| 78 | 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 | 104 | style := cell.Style.Canonical() | |
| 81 | - | if style != renderer.DefaultStyle() { | |
| 105 | + | if style != defaultStyle { | |
| 82 | 106 | lastStyleRow[style] = i - start | |
| 83 | 107 | } | |
| 84 | 108 | } | |
| @@ -300,8 +324,8 @@ func (h *History) recordTailRowStyles(width int) { | |||
| 300 | 324 | if h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) { | |
| 301 | 325 | h.tailPageWidth = width | |
| 302 | 326 | 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{}{} | |
| 305 | 329 | } | |
| 306 | 330 | for p := range h.payloadScratch { | |
| 307 | 331 | if h.tailPagePayloads == nil { | |
| @@ -315,13 +339,28 @@ func (h *History) recordTailRowStyles(width int) { | |||
| 315 | 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 | 358 | func (h *History) rebuildTailAccounting() { | |
| 319 | 359 | old := h.tailBytes | |
| 320 | 360 | h.tailBytes = 0 | |
| 321 | 361 | h.tailPageWidth = 0 | |
| 322 | 362 | h.tailPageRows = 0 | |
| 323 | - | h.tailPageStyles = nil | |
| 324 | - | h.tailPagePayloads = nil | |
| 363 | + | h.resetTailPageSets() | |
| 325 | 364 | for _, row := range h.tail { | |
| 326 | 365 | h.prepareRowStyles(row) | |
| 327 | 366 | delta := h.tailAppendDelta(len(row)) | |
| @@ -496,16 +535,26 @@ func (h *History) sealTail() { | |||
| 496 | 535 | chunks := h.tailViewChunks() | |
| 497 | 536 | h.chunks = append(h.chunks, chunks...) | |
| 498 | 537 | h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks) | |
| 499 | - | h.tail = nil | |
| 500 | 538 | 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] | |
| 504 | 555 | h.tailBytes = 0 | |
| 505 | 556 | h.tailPageWidth = 0 | |
| 506 | 557 | h.tailPageRows = 0 | |
| 507 | - | h.tailPageStyles = nil | |
| 508 | - | h.tailPagePayloads = nil | |
| 509 | 558 | } | |
| 510 | 559 | ||
| 511 | 560 | // evictFor discards oldest rows until row can fit both retention budgets. | |
| @@ -569,7 +618,13 @@ func (h *History) SealAndView() HistoryView { | |||
| 569 | 618 | ||
| 570 | 619 | func (h *History) tailViewChunks() []*HistoryChunk { | |
| 571 | 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 | 629 | return h.cachedTail | |
| 575 | 630 | } | |
| @@ -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 int | 43 | rows int | ||
| 43 | cells int | 44 | cells int | ||
| 44 | logicalBytes uint64 | 45 | 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 []*HistoryChunk | 78 | 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 - start | 106 | 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 = width | 325 | h.tailPageWidth = width | ||
| 302 | h.tailPageRows = 0 | 326 | h.tailPageRows = 0 | ||
| 303 | - | h.tailPageStyles = map[renderer.Style]struct{}{renderer.DefaultStyle(): {}} | 327 | + | h.resetTailPageSets() |
| 304 | - | h.tailPagePayloads = nil | 328 | + | 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.tailBytes | 359 | old := h.tailBytes | ||
| 320 | h.tailBytes = 0 | 360 | h.tailBytes = 0 | ||
| 321 | h.tailPageWidth = 0 | 361 | h.tailPageWidth = 0 | ||
| 322 | h.tailPageRows = 0 | 362 | h.tailPageRows = 0 | ||
| 323 | - | h.tailPageStyles = nil | 363 | + | 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 = nil | 538 | h.cachedTail = nil | ||
| 501 | - | h.tailCells = nil | 539 | + | // The sealed chunks hold compact copies (newHistoryChunks and the bounds and |
| 502 | - | h.tailBounds = nil | 540 | + | // ID slices are copied), and no view, snapshot or callback ever receives |
| 503 | - | h.tailIDs = nil | 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] | |||
| 504 | h.tailBytes = 0 | 555 | h.tailBytes = 0 | ||
| 505 | h.tailPageWidth = 0 | 556 | h.tailPageWidth = 0 | ||
| 506 | h.tailPageRows = 0 | 557 | 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.cachedTail | 629 | 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 | + | } | |||