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:
parent
ab60bb7f16
commit
3c8017f871
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user