Skip to content

Commit d83d398

Browse files
committed
backup: harden snapshot encoder core per M1 review (single-use guard + edge tests)
claude review on PR #825 found no blocking issues; addressed the two meaningful findings plus the cheap nits to harden the foundation the M2-M5 adapter feed loops build on: - WriteTo is now single-use: a second call fails closed with ErrSnapshotBuilderReused rather than silently re-emitting the already-written entries (a footgun once adapters feed the builder in loops). Caller audit: the only callers of WriteTo/newSnapshotBuilder are tests; the production caller lands with the M6 CLI, so no existing call site relies on reuse. - Add now size-checks from the user-buffer lengths BEFORE allocating the framed key/value, so an oversize record fails closed without a wasted allocation. - countingWriter.Write uses the idiomatic if-err-nil guard for consistency with the rest of the package. Tests: empty-builder header-only round-trip (fresh-cluster restore path), WriteTo reuse rejection, and a deterministic WriteTo byte-count assertion in the single-entry round-trip. Extracted assertWriteByteCount / assertSingleEntry helpers to keep the round-trip test under the cyclop budget. The gemini medium findings (unbounded slice / seen-map memory, reflect in binary.Write) are below the in-memory-sort bound the design already tracks as the external-sort follow-up milestone; not addressed here.
1 parent 69623d7 commit d83d398

2 files changed

Lines changed: 103 additions & 17 deletions

File tree

internal/backup/encode.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ var ErrEncodeDuplicateKey = errors.New("backup: duplicate encoded key in snapsho
5353
var ErrEncodeKeyTooLarge = errors.New("backup: encoded key length exceeds limit")
5454
var ErrEncodeValueTooLarge = errors.New("backup: encoded value length exceeds limit")
5555

56+
// ErrSnapshotBuilderReused is returned by WriteTo when it is called
57+
// more than once on the same builder. A builder is single-use (one
58+
// per encode run); a second WriteTo would re-emit the already-written
59+
// entries, producing a valid-but-unintended stream. Enforced so the
60+
// per-adapter feed loops in later milestones cannot silently double-
61+
// emit (claude review on PR #825).
62+
var ErrSnapshotBuilderReused = errors.New("backup: snapshotBuilder.WriteTo called more than once")
63+
5664
// ErrLastCommitTSRegression is returned by ResolveCommitTS when a
5765
// `--last-commit-ts` override is older than MANIFEST.last_commit_ts.
5866
// Seeding the restored node's HLC ceiling below a timestamp already
@@ -123,6 +131,7 @@ type snapshotBuilder struct {
123131
commitTS uint64
124132
entries []encodedKV
125133
seen map[string]struct{}
134+
written bool
126135
}
127136

128137
// newSnapshotBuilder constructs a builder that stamps every key with
@@ -141,21 +150,24 @@ func newSnapshotBuilder(commitTS uint64) *snapshotBuilder {
141150
// order-dependent `.fsm`. userKey/userValue are copied — callers may
142151
// reuse their buffers after Add returns.
143152
func (b *snapshotBuilder) Add(userKey, userValue []byte, expireAt uint64) error {
144-
key := encodeMVCCKey(userKey, b.commitTS)
145-
if uint64(len(key)) > MaxSnapshotEncodedKeySize {
153+
// Size-check from the user-buffer lengths before allocating the
154+
// framed buffers — encKey = userKey + snapshotTSSize, encVal =
155+
// snapshotValueHeaderSize + userValue — so an oversize record
156+
// fails closed without a wasted allocation (claude review nit).
157+
if uint64(len(userKey))+snapshotTSSize > MaxSnapshotEncodedKeySize {
146158
return errors.Wrapf(ErrEncodeKeyTooLarge,
147-
"length %d > %d", len(key), MaxSnapshotEncodedKeySize)
159+
"length %d > %d", len(userKey)+snapshotTSSize, MaxSnapshotEncodedKeySize)
148160
}
149-
val := encodeMVCCValue(userValue, expireAt)
150-
if uint64(len(val)) > MaxSnapshotEncodedValueSize {
161+
if uint64(len(userValue))+snapshotValueHeaderSize > MaxSnapshotEncodedValueSize {
151162
return errors.Wrapf(ErrEncodeValueTooLarge,
152-
"length %d > %d", len(val), MaxSnapshotEncodedValueSize)
163+
"length %d > %d", len(userValue)+snapshotValueHeaderSize, MaxSnapshotEncodedValueSize)
153164
}
165+
key := encodeMVCCKey(userKey, b.commitTS)
154166
if _, dup := b.seen[string(key)]; dup {
155167
return errors.Wrapf(ErrEncodeDuplicateKey, "userKey %q", userKey)
156168
}
157169
b.seen[string(key)] = struct{}{}
158-
b.entries = append(b.entries, encodedKV{key: key, val: val})
170+
b.entries = append(b.entries, encodedKV{key: key, val: encodeMVCCValue(userValue, expireAt)})
159171
return nil
160172
}
161173

@@ -169,6 +181,10 @@ func (b *snapshotBuilder) Len() int { return len(b.entries) }
169181
// format terminates on a clean EOF (store/snapshot_pebble.go WriteTo,
170182
// internal/backup/snapshot_reader.go ReadSnapshot).
171183
func (b *snapshotBuilder) WriteTo(w io.Writer) (int64, error) {
184+
if b.written {
185+
return 0, errors.WithStack(ErrSnapshotBuilderReused)
186+
}
187+
b.written = true
172188
sort.Slice(b.entries, func(i, j int) bool {
173189
return bytes.Compare(b.entries[i].key, b.entries[j].key) < 0
174190
})
@@ -214,7 +230,10 @@ type countingWriter struct {
214230
func (w *countingWriter) Write(p []byte) (int, error) {
215231
n, err := w.w.Write(p)
216232
w.n += int64(n)
217-
return n, errors.WithStack(err)
233+
if err != nil {
234+
return n, errors.WithStack(err)
235+
}
236+
return n, nil
218237
}
219238

220239
// RoundTripEntry is one live record recovered by DecodeLiveEntries.

internal/backup/encode_test.go

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,16 @@ func TestSnapshotBuilderRoundTrip(t *testing.T) {
101101
}
102102

103103
var buf bytes.Buffer
104-
if _, err := b.WriteTo(&buf); err != nil {
104+
n, err := b.WriteTo(&buf)
105+
if err != nil {
105106
t.Fatalf("WriteTo: %v", err)
106107
}
108+
// Byte count is deterministic: magic + lastCommitTS + per-entry
109+
// (8-byte len prefix + bytes) for both key and value.
110+
wantN := int64(len(PebbleSnapshotMagic) + 8 +
111+
(8 + len(userKey) + snapshotTSSize) +
112+
(8 + snapshotValueHeaderSize + len(userValue)))
113+
assertWriteByteCount(t, n, &buf, wantN)
107114

108115
entries, hdr, err := DecodeLiveEntries(&buf)
109116
if err != nil {
@@ -112,17 +119,35 @@ func TestSnapshotBuilderRoundTrip(t *testing.T) {
112119
if hdr.LastCommitTS != encodeFixtureTS {
113120
t.Fatalf("header LastCommitTS = %#x, want %#x", hdr.LastCommitTS, encodeFixtureTS)
114121
}
115-
if len(entries) != 1 {
116-
t.Fatalf("got %d entries, want 1", len(entries))
122+
assertSingleEntry(t, entries, userKey, userValue, expireAt)
123+
}
124+
125+
// assertWriteByteCount checks WriteTo's returned count matches the
126+
// expected value and the actual buffer length.
127+
func assertWriteByteCount(t *testing.T, n int64, buf *bytes.Buffer, want int64) {
128+
t.Helper()
129+
if n != want {
130+
t.Fatalf("WriteTo n = %d, want %d", n, want)
117131
}
118-
if !bytes.Equal(entries[0].UserKey, userKey) {
119-
t.Fatalf("UserKey = %q, want %q", entries[0].UserKey, userKey)
132+
if n != int64(buf.Len()) {
133+
t.Fatalf("WriteTo n = %d, but buf has %d bytes", n, buf.Len())
120134
}
121-
if !bytes.Equal(entries[0].UserValue, userValue) {
122-
t.Fatalf("UserValue = %q, want %q", entries[0].UserValue, userValue)
135+
}
136+
137+
// assertSingleEntry checks the decoded set has exactly one entry
138+
// matching the given key/value/expiry.
139+
func assertSingleEntry(t *testing.T, entries []RoundTripEntry, key, val []byte, exp uint64) {
140+
t.Helper()
141+
if len(entries) != 1 {
142+
t.Fatalf("got %d entries, want 1", len(entries))
123143
}
124-
if entries[0].ExpireAt != expireAt {
125-
t.Fatalf("ExpireAt = %d, want %d", entries[0].ExpireAt, expireAt)
144+
switch {
145+
case !bytes.Equal(entries[0].UserKey, key):
146+
t.Fatalf("UserKey = %q, want %q", entries[0].UserKey, key)
147+
case !bytes.Equal(entries[0].UserValue, val):
148+
t.Fatalf("UserValue = %q, want %q", entries[0].UserValue, val)
149+
case entries[0].ExpireAt != exp:
150+
t.Fatalf("ExpireAt = %d, want %d", entries[0].ExpireAt, exp)
126151
}
127152
}
128153

@@ -202,6 +227,48 @@ func TestSnapshotBuilderRejectsOversizeKey(t *testing.T) {
202227
}
203228
}
204229

230+
// TestSnapshotBuilderEmptyRoundTrip pins the empty-builder path (the
231+
// "fresh cluster / empty shard" restore case): WriteTo emits a valid
232+
// header-only stream that DecodeLiveEntries reads back as zero entries
233+
// with the header timestamp intact.
234+
func TestSnapshotBuilderEmptyRoundTrip(t *testing.T) {
235+
t.Parallel()
236+
b := newSnapshotBuilder(encodeFixtureTS)
237+
var buf bytes.Buffer
238+
if _, err := b.WriteTo(&buf); err != nil {
239+
t.Fatalf("WriteTo: %v", err)
240+
}
241+
entries, hdr, err := DecodeLiveEntries(&buf)
242+
if err != nil {
243+
t.Fatalf("DecodeLiveEntries: %v", err)
244+
}
245+
if hdr.LastCommitTS != encodeFixtureTS {
246+
t.Fatalf("hdr.LastCommitTS = %#x, want %#x", hdr.LastCommitTS, encodeFixtureTS)
247+
}
248+
if len(entries) != 0 {
249+
t.Fatalf("got %d entries, want 0", len(entries))
250+
}
251+
}
252+
253+
// TestSnapshotBuilderRejectsReuse pins that a builder is single-use:
254+
// a second WriteTo fails closed with ErrSnapshotBuilderReused rather
255+
// than silently re-emitting the already-written entries.
256+
func TestSnapshotBuilderRejectsReuse(t *testing.T) {
257+
t.Parallel()
258+
b := newSnapshotBuilder(encodeFixtureTS)
259+
if err := b.Add([]byte("!redis|str|k"), []byte("v"), 0); err != nil {
260+
t.Fatalf("Add: %v", err)
261+
}
262+
var buf bytes.Buffer
263+
if _, err := b.WriteTo(&buf); err != nil {
264+
t.Fatalf("first WriteTo: %v", err)
265+
}
266+
_, err := b.WriteTo(&buf)
267+
if !errors.Is(err, ErrSnapshotBuilderReused) {
268+
t.Fatalf("second WriteTo err = %v, want ErrSnapshotBuilderReused", err)
269+
}
270+
}
271+
205272
// TestResolveCommitTS pins the --last-commit-ts override semantics
206273
// (design §"MVCC re-encoding"): no override yields the manifest value;
207274
// an override >= manifest is accepted; an override < manifest fails

0 commit comments

Comments
 (0)