editor: split oversized chunks on insert (bound per-edit copy cost)

The chunked buffer was the right structure for this workload, but the
implementation let the edited chunk grow without bound (the type comment
even admitted 'not rebalanced'): sustained typing at one spot made that
chunk grow monotonically, so each subsequent insert copied the whole
grown chunk -- quadratic total copy work for sustained typing.

Insert now splits any chunk that grows past 2x the target size in half,
and the empty-buffer path chunks a large first paste directly instead of
creating one oversized chunk. The per-edit copy cost is now bounded by
O(chunkSize) by construction. The split cut is an arbitrary byte offset
(like SetContent boundaries): chunk edges may fall inside multi-byte
sequences, which is fine because readers reassemble whole line-aligned
windows from chunk bytes.

Shrunken/empty chunks from deletes are left in place: chunk count never
grows with deletes, all readers walk actual lengths, and removal would be
pure churn with no reader-side benefit.

No behavior change visible to readers (Content/FullContent/LineIndex all
walk actual chunk lengths); covered by three new tests: sustained-typing
chunk-size invariant + content, large paste into empty buffer, and
content integrity across newly created split boundaries. Full suite green
under -race.
This commit is contained in:
Greg Pomerantz 2026-08-16 22:36:15 -04:00
parent ab60bb7f16
commit 3c8017f871
2 changed files with 129 additions and 13 deletions

View File

@ -39,17 +39,19 @@ const (
// therefore no stale-disk re-read (the old fixed-slot model could re-read a
// shifted tail chunk from disk and clobber in-memory edits).
//
// Chunks may grow/shrink with edits and are not rebalanced; that is fine for
// correctness (prefix sums) and keeps edits local to the affected chunk(s)
// rather than copying the whole file.
// Chunks grow/shrink with edits; Insert splits any chunk that grows past
// twice the target size (see Insert), so the per-edit copy cost stays bounded
// by O(chunkSize) even under sustained typing at one spot. Shrunken (even
// empty) chunks are left in place: the chunk count never grows with deletes,
// all readers walk actual lengths, and removing chunks would be pure churn.
type ChunkedBuffer struct {
filename string
chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary
fileLen int64 // total content length
chunks [][]byte // ordered; chunks[i] is the i-th chunk
dirty bool // true if buffer has been modified
FS pool.FileSystem // filesystem for reads
basePath string // base path for file resolution
filename string
chunkSize int // target chunk size (e.g. 64 KB); actual chunks may vary
fileLen int64 // total content length
chunks [][]byte // ordered; chunks[i] is the i-th chunk
dirty bool // true if buffer has been modified
FS pool.FileSystem // filesystem for reads
basePath string // base path for file resolution
// line index is built asynchronously
LineIndex *types.LineIndex
@ -320,7 +322,15 @@ func (cb *ChunkedBuffer) markDirtyChunk(idx int) {
}
}
// Insert inserts text at byte position pos, splicing only the affected chunk.
// Insert inserts text at byte position pos, splicing only the affected
// chunk. If the result grows past twice the target chunk size (sustained
// typing at one spot, or a large paste), the chunk is split in half so that
// chunks stay O(chunkSize) and every future edit remains a bounded
// O(chunkSize) copy. The split cut is an arbitrary byte offset, like the
// chunk boundaries created by SetContent: chunk boundaries may fall inside
// multi-byte sequences, which is fine because every reader reassembles whole
// windows from chunk bytes (windows are always line-aligned, i.e. on rune
// boundaries).
func (cb *ChunkedBuffer) Insert(pos int, text string) {
if len(text) == 0 {
return
@ -334,8 +344,16 @@ func (cb *ChunkedBuffer) Insert(pos int, text string) {
}
idx, local := cb.chunkForPos(pos)
if idx < 0 {
// Empty buffer: create the first chunk.
cb.chunks = append(cb.chunks, []byte(text))
// Empty buffer: chunk the new text directly so a large first paste
// does not create one oversized chunk.
cb.chunks = nil
for start := 0; start < len(text); start += cb.chunkSize {
end := start + cb.chunkSize
if end > len(text) {
end = len(text)
}
cb.chunks = append(cb.chunks, []byte(text[start:end]))
}
cb.fileLen = int64(len(text))
cb.markDirtyChunk(0)
return
@ -345,6 +363,16 @@ func (cb *ChunkedBuffer) Insert(pos int, text string) {
newChunk = append(newChunk, chunk[:local]...)
newChunk = append(newChunk, text...)
newChunk = append(newChunk, chunk[local:]...)
if len(newChunk) > 2*cb.chunkSize {
// Split in half and insert the second half after idx.
cut := len(newChunk) / 2
second := make([]byte, len(newChunk)-cut)
copy(second, newChunk[cut:])
newChunk = newChunk[:cut]
cb.chunks = append(cb.chunks, nil)
copy(cb.chunks[idx+2:], cb.chunks[idx+1:]) // overlap-safe (memmove)
cb.chunks[idx+1] = second
}
cb.chunks[idx] = newChunk
cb.fileLen += int64(len(text))
cb.markDirtyChunk(idx)

View File

@ -191,3 +191,91 @@ func TestAppendAtEnd(t *testing.T) {
t.Fatalf("FileLen=%d, want 9", cb.FileLen())
}
}
// assertMaxChunkSize verifies the chunking invariant: no chunk may exceed
// twice the target size (Insert splits oversized chunks in half).
func assertMaxChunkSize(t *testing.T, cb *ChunkedBuffer) {
t.Helper()
for i, c := range cb.chunks {
if len(c) > 2*cb.chunkSize {
t.Fatalf("chunk %d is %d bytes, want <= %d (2 x %d)", i, len(c), 2*cb.chunkSize, cb.chunkSize)
}
}
}
// TestInsert_SustainedTyping_SplitsOversizedChunks simulates sustained typing
// at one spot (many small inserts into the same chunk). Without the split, the
// target chunk grows without bound and every edit copies the whole grown
// chunk (quadratic total). With the split, chunks stay O(chunkSize).
func TestInsert_SustainedTyping_SplitsOversizedChunks(t *testing.T) {
const chunkSize = 64
cb := NewChunkedBuffer("/test.txt", chunkSize, nil, "")
seed := strings.Repeat("seed line\n", chunkSize/8) // 64 bytes = 1 chunk
cb.SetContent([]byte(seed))
// Type 2000 characters, each inserted immediately after the previous one,
// starting at position 10 (inside chunk 0).
var typed strings.Builder
pos := 10
for i := 0; i < 2000; i++ {
r := rune('a' + i%26)
cb.Insert(pos, string(r))
pos++
typed.WriteRune(r)
if i%200 == 0 {
assertMaxChunkSize(t, cb)
}
}
assertMaxChunkSize(t, cb)
want := seed[:10] + typed.String() + seed[10:]
full, _ := cb.FullContent()
if full != want {
t.Fatalf("FullContent mismatch after sustained inserts (len %d vs %d)", len(full), len(want))
}
if cb.FileLen() != int64(len(want)) {
t.Fatalf("FileLen=%d, want %d", cb.FileLen(), len(want))
}
}
// TestInsert_LargePaste_EmptyBuffer verifies that a large first paste into an
// empty buffer is chunked (not one oversized chunk) and reconstructs exactly.
func TestInsert_LargePaste_EmptyBuffer(t *testing.T) {
cb := NewChunkedBuffer("/test.txt", 64*1024, nil, "")
paste := strings.Repeat("0123456789", 100*1024) // 1 MB
cb.Insert(0, paste)
assertMaxChunkSize(t, cb)
if got := len(cb.chunks); got != 16 {
t.Fatalf("expected 16 chunks for 1MB/64KB, got %d", got)
}
full, _ := cb.FullContent()
if full != paste {
t.Fatalf("FullContent mismatch after large paste")
}
}
// TestInsert_SplitContentIntegrity checks that Content stays exact across the
// NEW chunk boundary created by a split, including spans that cross it.
func TestInsert_SplitContentIntegrity(t *testing.T) {
const chunkSize = 64
cb := NewChunkedBuffer("/test.txt", chunkSize, nil, "")
cb.SetContent([]byte(strings.Repeat("x", 64)))
// 200 inserts of 2 chars at pos 0: the first chunk grows to 464+ bytes and
// must be split several times.
for i := 0; i < 200; i++ {
cb.Insert(0, "AB")
}
want := strings.Repeat("AB", 200) + strings.Repeat("x", 64)
full, _ := cb.FullContent()
if full != want {
t.Fatalf("FullContent mismatch: got %d bytes, want %d", len(full), len(want))
}
// Span across several chunk boundaries near the front.
seg := cb.Content(0, 200)
if seg != want[:200] {
t.Fatalf("Content(0,200) mismatch across split boundary")
}
seg = cb.Content(50, 300)
if seg != want[50:300] {
t.Fatalf("Content(50,300) mismatch across split boundary")
}
}