v0.5.1 8e4df5c

history_storage.go RawHistoryFolder

1package vt
2
3import (
4	"bytes"
5	"compress/zlib"
6	"errors"
7	"fmt"
8	"io"
9	"sync"
10
11	renderer "github.com/bnema/vev-vt/core"
12)
13
14// ErrHistoryCorrupt reports corruption of an internally compressed sealed page.
15// Public history decoders reject malformed external data before installing it.
16var ErrHistoryCorrupt = errors.New("corrupt compressed history page")
17
18const maxHistoryPageEncodedBytes = 256 << 20
19
20// sealedPage owns immutable semantic contents and mutable physical backing.
21// Evicted-prefix wrappers and borrowed views share this owner. A returned Frame
22// is an internal read-only borrow: dropping the cached pointer cannot invalidate
23// an in-flight reader because Go keeps that reader's page reference alive.
24type sealedPage struct {
25	mu              sync.Mutex
26	frame           renderer.Frame
27	compressed      []byte
28	encodedSize     int
29	width, height   int
30	reads, observed uint64
31	observedOnce    bool
32	incompressible  bool
33	restores        uint64
34}
35
36func newSealedPage(frame renderer.Frame) *sealedPage {
37	return &sealedPage{frame: frame, width: frame.Width, height: frame.Height}
38}
39
40func (p *sealedPage) readFrame(cache bool) (renderer.Frame, error) {
41	p.mu.Lock()
42	defer p.mu.Unlock()
43	p.reads++
44	if p.frame.Height != 0 {
45		return p.frame, nil
46	}
47	frame, err := p.decode()
48	if err != nil {
49		return renderer.Frame{}, err
50	}
51	p.restores++
52	if cache {
53		p.frame = frame
54	}
55	return frame, nil
56}
57
58// decode is called with the page lock held. The exact uncompressed length and
59// zlib checksum are validated before VTC1's full semantic preflight runs.
60func (p *sealedPage) decode() (renderer.Frame, error) {
61	invalid := func(err error) (renderer.Frame, error) {
62		return renderer.Frame{}, fmt.Errorf("%w: %v", ErrHistoryCorrupt, err)
63	}
64	if p.encodedSize < historyHeaderBytes || p.encodedSize > maxHistoryPageEncodedBytes || len(p.compressed) == 0 {
65		return invalid(errors.New("missing backing"))
66	}
67	source := bytes.NewReader(p.compressed)
68	r, err := zlib.NewReader(source)
69	if err != nil {
70		return invalid(err)
71	}
72	data, readErr := io.ReadAll(io.LimitReader(r, int64(p.encodedSize)+1))
73	closeErr := r.Close()
74	if readErr != nil {
75		return invalid(readErr)
76	}
77	if closeErr != nil {
78		return invalid(closeErr)
79	}
80	if len(data) != p.encodedSize || source.Len() != 0 {
81		return invalid(errors.New("backing length mismatch"))
82	}
83	view, err := UnmarshalHistory(data)
84	if err != nil {
85		return invalid(err)
86	}
87	if len(view.chunks) != 1 {
88		return invalid(errors.New("unexpected page count"))
89	}
90	frame := view.chunks[0].page.frame
91	if frame.Width != p.width || frame.Height != p.height {
92		return invalid(errors.New("page geometry mismatch"))
93	}
94	return frame, nil
95}
96
97func (c *HistoryChunk) frameView() renderer.Frame {
98	frame, err := c.page.readFrame(true)
99	if err != nil {
100		panic(err)
101	} // Never substitute blank data for corrupted history.
102	return frame
103}
104
105// Restore validates and caches the chunk's backing before a caller starts a
106// read transaction. Ordinary reads restore transparently; if private backing is
107// corrupted they panic with ErrHistoryCorrupt rather than silently losing text.
108func (c *HistoryChunk) Restore() error {
109	if c == nil {
110		return nil
111	}
112	_, err := c.page.readFrame(true)
113	return err
114}
115
116func (p *sealedPage) compressIfIdle() (bool, error) {
117	p.mu.Lock()
118	defer p.mu.Unlock()
119	if !p.observedOnce || p.observed != p.reads {
120		p.observed, p.observedOnce = p.reads, true
121		return false, nil
122	}
123	if p.frame.Height == 0 || p.incompressible {
124		return false, nil
125	}
126	if len(p.compressed) != 0 {
127		p.frame = renderer.Frame{}
128		return true, nil
129	}
130	// Encode the full backing, not an evicted-prefix wrapper. Synthetic IDs are
131	// local to this private envelope; public stable IDs remain on the wrappers.
132	chunk := &HistoryChunk{page: newSealedPage(p.frame), count: p.height, width: p.width,
133		bounds: make([]LineBound, p.height), rowIDs: make([]RowID, p.height)}
134	for y := range p.height {
135		chunk.bounds[y] = LineBound{End: p.width}
136		chunk.rowIDs[y] = RowID(y + 1)
137	}
138	raw, err := MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: p.height, nextRowID: RowID(p.height + 1)})
139	if err != nil {
140		return false, err
141	}
142	if len(raw) > maxHistoryPageEncodedBytes {
143		return false, errors.New("history page exceeds encoded backing limit")
144	}
145	var out bytes.Buffer
146	w, err := zlib.NewWriterLevel(&out, zlib.BestSpeed)
147	if err != nil {
148		return false, err
149	}
150	if _, err := w.Write(raw); err != nil {
151		_ = w.Close()
152		return false, err
153	}
154	if err := w.Close(); err != nil {
155		return false, err
156	}
157	if out.Len() >= len(raw) {
158		p.incompressible = true
159		return false, nil
160	}
161	p.compressed = make([]byte, out.Len())
162	copy(p.compressed, out.Bytes())
163	p.encodedSize = len(raw)
164	p.frame = renderer.Frame{}
165	return true, nil
166}
167
168// CompressIdle visits at most maxPages sealed pages. Call it from the history
169// owner's idle scheduler, never concurrently with append/eviction. A page must
170// remain unread across two visits; the newest sealed chunk and mutable tail are
171// kept hot. There are no internal goroutines, clocks, pooling or mmap mappings.
172func (h *History) CompressIdle(maxPages int) (int, error) {
173	if h == nil || maxPages <= 0 || len(h.chunks) < 2 {
174		return 0, nil
175	}
176	eligible := len(h.chunks) - 1
177	compressed := 0
178	for range min(maxPages, eligible) {
179		h.compressionCursor %= eligible
180		chunk := h.chunks[h.compressionCursor]
181		h.compressionCursor++
182		didCompress, err := chunk.page.compressIfIdle()
183		if err != nil {
184			return compressed, err
185		}
186		if didCompress {
187			compressed++
188		}
189	}
190	return compressed, nil
191}
192
193// HistoryCompressionStats describes retained physical backing, not process RSS.
194// ResidentLogicalBytes excludes Go allocator/map overhead; CompressedBytes is
195// the encoded backing size. Retention remains governed by LogicalBytes alone.
196type HistoryCompressionStats struct {
197	ColdPages            int
198	CompressedBytes      uint64
199	ResidentLogicalBytes uint64
200	Restores             uint64
201}
202
203func (h *History) CompressionStats() HistoryCompressionStats {
204	var stats HistoryCompressionStats
205	if h == nil {
206		return stats
207	}
208	stats.ResidentLogicalBytes = h.tailBytes
209	for _, chunk := range h.chunks {
210		p := chunk.page
211		p.mu.Lock()
212		if p.frame.Height == 0 {
213			stats.ColdPages++
214		} else {
215			stats.ResidentLogicalBytes += p.frame.LogicalBytes()
216		}
217		stats.CompressedBytes += uint64(len(p.compressed))
218		stats.Restores += p.restores
219		p.mu.Unlock()
220	}
221	return stats
222}

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