detached 2de317d

feat(kv): one log record per writer group, zerowrap logging, ctx on every operation

Log format: a record now holds a whole writer group (seq of the first
batch, batch count, batches). Recovery replays per batch, handles a
record that straddles the snapshot seq and treats any readable record
after a damaged one as proof of acknowledged damage (rule 6), so a
power loss that keeps pages of an unsynced group out of order is a
torn tail, not ErrCorrupt. MaxBatchBytes is limited to maxRecord-12.

Logging: slog and Options.Logger are replaced by zerowrap loggers taken
from the ctx given to Open (component and dir fields). Levels: info
open/close, warn torn tail and skipped snapshot, error failed state,
debug per group.

API: Open, Close, Snapshot, DB.Get, Snapshot.Get/Range/Prefix and Apply
take a ctx first; reads ignore it and stay at 0 allocs/op.

Tests cover KeepPages power loss with synced commits.
commit
2de317dad554329db787ee3fcfb0030a131a4176
author
brice · · 2026-10-10 02:31 UTC
parent
b496766
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +578 -299
M batch.go +5 -4
@@ -80,13 +80,14 @@ func (b *Batch) Reset() {
8080 b.err = nil
8181 }
8282
83-// payload returns the encoded record payload size; 0 for a batch without
84-// ops, which writes nothing.
85-func (b *Batch) payload() int {
83+// encodedSize returns the size of the batch in a log record (op count and
84+// ops), which MaxBatchBytes limits; 0 for a batch without ops, which writes
85+// nothing.
86+func (b *Batch) encodedSize() int {
8687 if len(b.ops) == 0 {
8788 return 0
8889 }
89- return recordMinBody + b.opsBytes
90+ return batchHeader + b.opsBytes
9091 }
9192
9293 func (b *Batch) add(o op) {
@@ -80,13 +80,14 @@ func (b *Batch) Reset() {
80 b.err = nil80 b.err = nil
81 }81 }
82 82
83-// payload returns the encoded record payload size; 0 for a batch without83+// encodedSize returns the size of the batch in a log record (op count and
84-// ops, which writes nothing.84+// ops), which MaxBatchBytes limits; 0 for a batch without ops, which writes
85-func (b *Batch) payload() int {85+// nothing.
86+func (b *Batch) encodedSize() int {
86 if len(b.ops) == 0 {87 if len(b.ops) == 0 {
87 return 088 return 0
88 }89 }
89- return recordMinBody + b.opsBytes90+ return batchHeader + b.opsBytes
90 }91 }
91 92
92 func (b *Batch) add(o op) {93 func (b *Batch) add(o op) {
M batch_test.go +8 -8
@@ -52,8 +52,8 @@ func TestBatchErrors(t *testing.T) {
5252 t.Fatalf("err = %v, want ErrInvalidKey", b.err)
5353 }
5454 b.Reset()
55- if b.err != nil || b.Len() != 0 || b.payload() != 0 {
56- t.Fatalf("Reset left err=%v len=%d payload=%d", b.err, b.Len(), b.payload())
55+ if b.err != nil || b.Len() != 0 || b.encodedSize() != 0 {
56+ t.Fatalf("Reset left err=%v len=%d size=%d", b.err, b.Len(), b.encodedSize())
5757 }
5858 })
5959 }
@@ -65,19 +65,19 @@ func TestBatchErrors(t *testing.T) {
6565 }
6666 }
6767
68-func TestBatchPayload(t *testing.T) {
68+func TestBatchEncodedSize(t *testing.T) {
6969 var b Batch
70- if b.payload() != 0 {
70+ if b.encodedSize() != 0 {
7171 t.Fatal("empty batch has a payload")
7272 }
7373 b.RequireAbsent([]byte("k"))
74- if b.payload() != 0 {
74+ if b.encodedSize() != 0 {
7575 t.Fatal("precondition-only batch has a payload")
7676 }
7777 b.Put([]byte("k"), []byte(strings.Repeat("v", 200)))
7878 b.Delete([]byte("kk"))
79- want := len(appendRecord(nil, 1, b.ops)) - recordFrame
80- if got := b.payload(); got != want {
81- t.Fatalf("payload = %d, encoded record payload = %d", got, want)
79+ want := len(appendRecord(nil, 1, b.ops)) - recordFrame - recordHead
80+ if got := b.encodedSize(); got != want {
81+ t.Fatalf("payload = %d, encoded batch = %d", got, want)
8282 }
8383 }
@@ -52,8 +52,8 @@ func TestBatchErrors(t *testing.T) {
52 t.Fatalf("err = %v, want ErrInvalidKey", b.err)52 t.Fatalf("err = %v, want ErrInvalidKey", b.err)
53 }53 }
54 b.Reset()54 b.Reset()
55- if b.err != nil || b.Len() != 0 || b.payload() != 0 {55+ if b.err != nil || b.Len() != 0 || b.encodedSize() != 0 {
56- t.Fatalf("Reset left err=%v len=%d payload=%d", b.err, b.Len(), b.payload())56+ t.Fatalf("Reset left err=%v len=%d size=%d", b.err, b.Len(), b.encodedSize())
57 }57 }
58 })58 })
59 }59 }
@@ -65,19 +65,19 @@ func TestBatchErrors(t *testing.T) {
65 }65 }
66 }66 }
67 67
68-func TestBatchPayload(t *testing.T) {68+func TestBatchEncodedSize(t *testing.T) {
69 var b Batch69 var b Batch
70- if b.payload() != 0 {70+ if b.encodedSize() != 0 {
71 t.Fatal("empty batch has a payload")71 t.Fatal("empty batch has a payload")
72 }72 }
73 b.RequireAbsent([]byte("k"))73 b.RequireAbsent([]byte("k"))
74- if b.payload() != 0 {74+ if b.encodedSize() != 0 {
75 t.Fatal("precondition-only batch has a payload")75 t.Fatal("precondition-only batch has a payload")
76 }76 }
77 b.Put([]byte("k"), []byte(strings.Repeat("v", 200)))77 b.Put([]byte("k"), []byte(strings.Repeat("v", 200)))
78 b.Delete([]byte("kk"))78 b.Delete([]byte("kk"))
79- want := len(appendRecord(nil, 1, b.ops)) - recordFrame79+ want := len(appendRecord(nil, 1, b.ops)) - recordFrame - recordHead
80- if got := b.payload(); got != want {80+ if got := b.encodedSize(); got != want {
81- t.Fatalf("payload = %d, encoded record payload = %d", got, want)81+ t.Fatalf("payload = %d, encoded batch = %d", got, want)
82 }82 }
83 }83 }
M db.go +47 -26
@@ -6,11 +6,12 @@ import (
66 "context"
77 "fmt"
88 "io"
9- "log/slog"
109 "sync"
1110 "sync/atomic"
1211 "time"
1312
13+ "github.com/bnema/zerowrap"
14+
1415 "bnema.dev/ermite-kv/internal/fsys"
1516 )
1617
@@ -18,16 +19,23 @@ import (
1819 const (
1920 defaultMaxBatchBytes = 16 << 20
2021 defaultCompactAfter = 64 << 20
22+
23+ // maxBatchLimit is the largest encoded batch that fits in a record
24+ // together with the record head (seq and batch count).
25+ maxBatchLimit = maxRecord - recordHead
2126 )
2227
2328 // Options configures Open. The zero value of every field means "default";
2429 // Options{} is safe and durable.
2530 type Options struct {
2631 // NoSync skips fdatasync on commits. For tests and benchmarks only: an
27- // acknowledged batch may be lost on power failure.
32+ // acknowledged batch may be lost on power failure, and a power loss can
33+ // leave a log that Open rejects with ErrCorrupt (the pages of one
34+ // unsynced group may reach the disk out of order, which recovery cannot
35+ // tell from damage to acknowledged data).
2836 NoSync bool
29- // MaxBatchBytes is the encoded batch limit. 0 means 16 MiB; it must be
30- // at most 64 MiB (the format limit).
37+ // MaxBatchBytes is the encoded batch limit (op count and ops). 0 means
38+ // 16 MiB; it must be at most 64 MiB minus 12 bytes (the format limit).
3139 MaxBatchBytes int
3240 // CompactAfter is the number of bytes in the current log that trigger
3341 // compaction. 0 means 64 MiB; a negative value disables compaction.
@@ -35,15 +43,13 @@ type Options struct {
3543 // GroupWindow is the extra time the writer waits to grow a commit
3644 // group. 0 means it only drains what is already queued.
3745 GroupWindow time.Duration
38- // Logger receives warnings and errors. nil discards them.
39- Logger *slog.Logger
4046 }
4147
4248 // resolve validates o and fills in the defaults.
4349 func (o Options) resolve() (Options, error) {
4450 switch {
45- case o.MaxBatchBytes < 0 || o.MaxBatchBytes > maxRecord:
46- return o, fmt.Errorf("kv: invalid MaxBatchBytes %d: must be in [0, %d]", o.MaxBatchBytes, maxRecord)
51+ case o.MaxBatchBytes < 0 || o.MaxBatchBytes > maxBatchLimit:
52+ return o, fmt.Errorf("kv: invalid MaxBatchBytes %d: must be in [0, %d]", o.MaxBatchBytes, maxBatchLimit)
4753 case o.GroupWindow < 0:
4854 return o, fmt.Errorf("kv: invalid GroupWindow %v: must not be negative", o.GroupWindow)
4955 }
@@ -53,9 +59,6 @@ func (o Options) resolve() (Options, error) {
5359 if o.CompactAfter == 0 {
5460 o.CompactAfter = defaultCompactAfter
5561 }
56- if o.Logger == nil {
57- o.Logger = slog.New(slog.DiscardHandler)
58- }
5962 return o, nil
6063 }
6164
@@ -63,7 +66,9 @@ func (o Options) resolve() (Options, error) {
6366 // for concurrent use.
6467 type DB struct {
6568 fx fsys.FS
69+ dir string
6670 opts Options
71+ log zerowrap.Logger // captured at Open; used by the writer
6772 snap atomic.Pointer[Snapshot] // last published state
6873
6974 reqCh chan *request // never closed
@@ -75,12 +80,11 @@ type DB struct {
7580 failErr error // first cause, wrapping ErrFailed; set before failed is closed
7681
7782 closeOnce sync.Once
78- closeErr error
7983
8084 lock io.Closer
8185
8286 // Owned by the writer goroutine (see writer.go).
83- log fsys.File
87+ file fsys.File
8488 logBase uint64 // seq the current log starts after
8589 logSize int64 // bytes in the current log, header included
8690 buf []byte // group encoding buffer
@@ -91,17 +95,23 @@ type DB struct {
9195 // takes an exclusive lock on the directory, returning ErrLocked if another
9296 // DB holds it, and recovers the state from the snapshot and logs. Invalid
9397 // options are an error.
94-func Open(dir string, opts Options) (*DB, error) {
95- return open(fsys.OS(), dir, opts)
98+//
99+// The logger of ctx (zerowrap.FromCtx), enriched with the component and the
100+// directory, is used for the life of the DB; ctx is not used for anything
101+// else and does not bound the life of the DB.
102+func Open(ctx context.Context, dir string, opts Options) (*DB, error) {
103+ return open(ctx, fsys.OS(), dir, opts)
96104 }
97105
98-func open(fx fsys.FS, dir string, opts Options) (*DB, error) {
106+func open(ctx context.Context, fx fsys.FS, dir string, opts Options) (*DB, error) {
99107 opts, err := opts.resolve()
100108 if err != nil {
101109 return nil, err
102110 }
111+ start := time.Now()
112+ log := zerowrap.FromCtxWithFields(ctx, map[string]any{"component": "ermite-kv", "dir": dir})
103113 bld := tree{}.builder()
104- rec, err := recoverDir(fx, dir, opts.Logger,
114+ rec, err := recoverDir(fx, dir, log,
105115 func(key, value []byte, version uint64) {
106116 // Snapshot entries alias one shared file buffer: copy them so
107117 // the tree does not keep the whole buffer alive.
@@ -124,29 +134,36 @@ func open(fx fsys.FS, dir string, opts Options) (*DB, error) {
124134 }
125135 db := &DB{
126136 fx: fx,
137+ dir: dir,
127138 opts: opts,
139+ log: log,
128140 reqCh: make(chan *request),
129141 closing: make(chan struct{}),
130142 done: make(chan struct{}),
131143 failed: make(chan struct{}),
132144 lock: rec.lock,
133- log: rec.log,
145+ file: rec.log,
134146 logBase: rec.logBase,
135147 logSize: rec.logSize,
136148 }
137- db.snap.Store(newSnapshot(bld.freeze(), rec.lastSeq))
149+ t := bld.freeze()
150+ db.snap.Store(newSnapshot(t, rec.lastSeq))
138151 go db.writer()
152+ log.Info().Uint64("seq", rec.lastSeq).Int("keys", t.len()).Uint64("snapshot_seq", rec.snapSeq).
153+ Dur("duration", time.Since(start)).Msg("opened")
139154 return db, nil
140155 }
141156
142157 // Close stops the database: requests not yet taken by the writer get
143158 // ErrClosed, the group in progress is committed and acknowledged, then the
144159 // log is closed and the directory lock released. Snapshots stay readable.
160+// Close always completes: ctx is only used for the logger of the closing
161+// message.
145162 //
146163 // Close returns the first error met while stopping, including the cause of
147164 // the failed state if the database failed, or nil. It is idempotent: later
148165 // calls return nil.
149-func (db *DB) Close() error {
166+func (db *DB) Close(ctx context.Context) error {
150167 var err error
151168 db.closeOnce.Do(func() {
152169 close(db.closing)
@@ -157,22 +174,26 @@ func (db *DB) Close() error {
157174 err = db.failErr
158175 default:
159176 }
160- if cerr := db.log.Close(); err == nil {
177+ if cerr := db.file.Close(); err == nil {
161178 err = cerr
162179 }
163180 if cerr := db.lock.Close(); err == nil {
164181 err = cerr
165182 }
183+ log := zerowrap.FromCtxWithFields(ctx, map[string]any{"component": "ermite-kv", "dir": db.dir})
184+ log.Info().Uint64("seq", db.snap.Load().Seq()).Err(err).Msg("closed")
166185 })
167186 return err
168187 }
169188
170189 // Snapshot returns the current immutable state. It is lock-free and O(1), and
171-// remains usable after Close.
172-func (db *DB) Snapshot() *Snapshot { return db.snap.Load() }
190+// remains usable after Close. Like every read, it ignores ctx.
191+func (db *DB) Snapshot(_ context.Context) *Snapshot { return db.snap.Load() }
173192
174193 // Get returns the item stored under key in the current snapshot.
175-func (db *DB) Get(key []byte) (Item, bool) { return db.snap.Load().Get(key) }
194+func (db *DB) Get(ctx context.Context, key []byte) (Item, bool) {
195+ return db.snap.Load().Get(ctx, key)
196+}
176197
177198 // Apply commits b atomically and returns its seq once it is durable (unless
178199 // Options.NoSync) and visible in Snapshot.
@@ -193,7 +214,7 @@ func (db *DB) Apply(ctx context.Context, b *Batch) (uint64, error) {
193214 if b.err != nil {
194215 return 0, b.err
195216 }
196- if b.payload() > db.opts.MaxBatchBytes {
217+ if b.encodedSize() > db.opts.MaxBatchBytes {
197218 return 0, ErrBatchTooLarge
198219 }
199220 select {
@@ -219,6 +240,6 @@ func (db *DB) Apply(ctx context.Context, b *Batch) (uint64, error) {
219240 return 0, r.release(db.failErr)
220241 }
221242 res := <-r.reply // the writer always answers a request it has taken
222- r.release(nil)
243+ _ = r.release(nil)
223244 return res.seq, res.err
224245 }
@@ -6,11 +6,12 @@ import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8 "io"8 "io"
9- "log/slog"
10 "sync"9 "sync"
11 "sync/atomic"10 "sync/atomic"
12 "time"11 "time"
13 12
13+ "github.com/bnema/zerowrap"
14+
14 "bnema.dev/ermite-kv/internal/fsys"15 "bnema.dev/ermite-kv/internal/fsys"
15 )16 )
16 17
@@ -18,16 +19,23 @@ import (
18 const (19 const (
19 defaultMaxBatchBytes = 16 << 2020 defaultMaxBatchBytes = 16 << 20
20 defaultCompactAfter = 64 << 2021 defaultCompactAfter = 64 << 20
22+
23+ // maxBatchLimit is the largest encoded batch that fits in a record
24+ // together with the record head (seq and batch count).
25+ maxBatchLimit = maxRecord - recordHead
21 )26 )
22 27
23 // Options configures Open. The zero value of every field means "default";28 // Options configures Open. The zero value of every field means "default";
24 // Options{} is safe and durable.29 // Options{} is safe and durable.
25 type Options struct {30 type Options struct {
26 // NoSync skips fdatasync on commits. For tests and benchmarks only: an31 // NoSync skips fdatasync on commits. For tests and benchmarks only: an
27- // acknowledged batch may be lost on power failure.32+ // acknowledged batch may be lost on power failure, and a power loss can
33+ // leave a log that Open rejects with ErrCorrupt (the pages of one
34+ // unsynced group may reach the disk out of order, which recovery cannot
35+ // tell from damage to acknowledged data).
28 NoSync bool36 NoSync bool
29- // MaxBatchBytes is the encoded batch limit. 0 means 16 MiB; it must be37+ // MaxBatchBytes is the encoded batch limit (op count and ops). 0 means
30- // at most 64 MiB (the format limit).38+ // 16 MiB; it must be at most 64 MiB minus 12 bytes (the format limit).
31 MaxBatchBytes int39 MaxBatchBytes int
32 // CompactAfter is the number of bytes in the current log that trigger40 // CompactAfter is the number of bytes in the current log that trigger
33 // compaction. 0 means 64 MiB; a negative value disables compaction.41 // compaction. 0 means 64 MiB; a negative value disables compaction.
@@ -35,15 +43,13 @@ type Options struct {
35 // GroupWindow is the extra time the writer waits to grow a commit43 // GroupWindow is the extra time the writer waits to grow a commit
36 // group. 0 means it only drains what is already queued.44 // group. 0 means it only drains what is already queued.
37 GroupWindow time.Duration45 GroupWindow time.Duration
38- // Logger receives warnings and errors. nil discards them.
39- Logger *slog.Logger
40 }46 }
41 47
42 // resolve validates o and fills in the defaults.48 // resolve validates o and fills in the defaults.
43 func (o Options) resolve() (Options, error) {49 func (o Options) resolve() (Options, error) {
44 switch {50 switch {
45- case o.MaxBatchBytes < 0 || o.MaxBatchBytes > maxRecord:51+ case o.MaxBatchBytes < 0 || o.MaxBatchBytes > maxBatchLimit:
46- return o, fmt.Errorf("kv: invalid MaxBatchBytes %d: must be in [0, %d]", o.MaxBatchBytes, maxRecord)52+ return o, fmt.Errorf("kv: invalid MaxBatchBytes %d: must be in [0, %d]", o.MaxBatchBytes, maxBatchLimit)
47 case o.GroupWindow < 0:53 case o.GroupWindow < 0:
48 return o, fmt.Errorf("kv: invalid GroupWindow %v: must not be negative", o.GroupWindow)54 return o, fmt.Errorf("kv: invalid GroupWindow %v: must not be negative", o.GroupWindow)
49 }55 }
@@ -53,9 +59,6 @@ func (o Options) resolve() (Options, error) {
53 if o.CompactAfter == 0 {59 if o.CompactAfter == 0 {
54 o.CompactAfter = defaultCompactAfter60 o.CompactAfter = defaultCompactAfter
55 }61 }
56- if o.Logger == nil {
57- o.Logger = slog.New(slog.DiscardHandler)
58- }
59 return o, nil62 return o, nil
60 }63 }
61 64
@@ -63,7 +66,9 @@ func (o Options) resolve() (Options, error) {
63 // for concurrent use.66 // for concurrent use.
64 type DB struct {67 type DB struct {
65 fx fsys.FS68 fx fsys.FS
69+ dir string
66 opts Options70 opts Options
71+ log zerowrap.Logger // captured at Open; used by the writer
67 snap atomic.Pointer[Snapshot] // last published state72 snap atomic.Pointer[Snapshot] // last published state
68 73
69 reqCh chan *request // never closed74 reqCh chan *request // never closed
@@ -75,12 +80,11 @@ type DB struct {
75 failErr error // first cause, wrapping ErrFailed; set before failed is closed80 failErr error // first cause, wrapping ErrFailed; set before failed is closed
76 81
77 closeOnce sync.Once82 closeOnce sync.Once
78- closeErr error
79 83
80 lock io.Closer84 lock io.Closer
81 85
82 // Owned by the writer goroutine (see writer.go).86 // Owned by the writer goroutine (see writer.go).
83- log fsys.File87+ file fsys.File
84 logBase uint64 // seq the current log starts after88 logBase uint64 // seq the current log starts after
85 logSize int64 // bytes in the current log, header included89 logSize int64 // bytes in the current log, header included
86 buf []byte // group encoding buffer90 buf []byte // group encoding buffer
@@ -91,17 +95,23 @@ type DB struct {
91 // takes an exclusive lock on the directory, returning ErrLocked if another95 // takes an exclusive lock on the directory, returning ErrLocked if another
92 // DB holds it, and recovers the state from the snapshot and logs. Invalid96 // DB holds it, and recovers the state from the snapshot and logs. Invalid
93 // options are an error.97 // options are an error.
94-func Open(dir string, opts Options) (*DB, error) {98+//
95- return open(fsys.OS(), dir, opts)99+// The logger of ctx (zerowrap.FromCtx), enriched with the component and the
100+// directory, is used for the life of the DB; ctx is not used for anything
101+// else and does not bound the life of the DB.
102+func Open(ctx context.Context, dir string, opts Options) (*DB, error) {
103+ return open(ctx, fsys.OS(), dir, opts)
96 }104 }
97 105
98-func open(fx fsys.FS, dir string, opts Options) (*DB, error) {106+func open(ctx context.Context, fx fsys.FS, dir string, opts Options) (*DB, error) {
99 opts, err := opts.resolve()107 opts, err := opts.resolve()
100 if err != nil {108 if err != nil {
101 return nil, err109 return nil, err
102 }110 }
111+ start := time.Now()
112+ log := zerowrap.FromCtxWithFields(ctx, map[string]any{"component": "ermite-kv", "dir": dir})
103 bld := tree{}.builder()113 bld := tree{}.builder()
104- rec, err := recoverDir(fx, dir, opts.Logger,114+ rec, err := recoverDir(fx, dir, log,
105 func(key, value []byte, version uint64) {115 func(key, value []byte, version uint64) {
106 // Snapshot entries alias one shared file buffer: copy them so116 // Snapshot entries alias one shared file buffer: copy them so
107 // the tree does not keep the whole buffer alive.117 // the tree does not keep the whole buffer alive.
@@ -124,29 +134,36 @@ func open(fx fsys.FS, dir string, opts Options) (*DB, error) {
124 }134 }
125 db := &DB{135 db := &DB{
126 fx: fx,136 fx: fx,
137+ dir: dir,
127 opts: opts,138 opts: opts,
139+ log: log,
128 reqCh: make(chan *request),140 reqCh: make(chan *request),
129 closing: make(chan struct{}),141 closing: make(chan struct{}),
130 done: make(chan struct{}),142 done: make(chan struct{}),
131 failed: make(chan struct{}),143 failed: make(chan struct{}),
132 lock: rec.lock,144 lock: rec.lock,
133- log: rec.log,145+ file: rec.log,
134 logBase: rec.logBase,146 logBase: rec.logBase,
135 logSize: rec.logSize,147 logSize: rec.logSize,
136 }148 }
137- db.snap.Store(newSnapshot(bld.freeze(), rec.lastSeq))149+ t := bld.freeze()
150+ db.snap.Store(newSnapshot(t, rec.lastSeq))
138 go db.writer()151 go db.writer()
152+ log.Info().Uint64("seq", rec.lastSeq).Int("keys", t.len()).Uint64("snapshot_seq", rec.snapSeq).
153+ Dur("duration", time.Since(start)).Msg("opened")
139 return db, nil154 return db, nil
140 }155 }
141 156
142 // Close stops the database: requests not yet taken by the writer get157 // Close stops the database: requests not yet taken by the writer get
143 // ErrClosed, the group in progress is committed and acknowledged, then the158 // ErrClosed, the group in progress is committed and acknowledged, then the
144 // log is closed and the directory lock released. Snapshots stay readable.159 // log is closed and the directory lock released. Snapshots stay readable.
160+// Close always completes: ctx is only used for the logger of the closing
161+// message.
145 //162 //
146 // Close returns the first error met while stopping, including the cause of163 // Close returns the first error met while stopping, including the cause of
147 // the failed state if the database failed, or nil. It is idempotent: later164 // the failed state if the database failed, or nil. It is idempotent: later
148 // calls return nil.165 // calls return nil.
149-func (db *DB) Close() error {166+func (db *DB) Close(ctx context.Context) error {
150 var err error167 var err error
151 db.closeOnce.Do(func() {168 db.closeOnce.Do(func() {
152 close(db.closing)169 close(db.closing)
@@ -157,22 +174,26 @@ func (db *DB) Close() error {
157 err = db.failErr174 err = db.failErr
158 default:175 default:
159 }176 }
160- if cerr := db.log.Close(); err == nil {177+ if cerr := db.file.Close(); err == nil {
161 err = cerr178 err = cerr
162 }179 }
163 if cerr := db.lock.Close(); err == nil {180 if cerr := db.lock.Close(); err == nil {
164 err = cerr181 err = cerr
165 }182 }
183+ log := zerowrap.FromCtxWithFields(ctx, map[string]any{"component": "ermite-kv", "dir": db.dir})
184+ log.Info().Uint64("seq", db.snap.Load().Seq()).Err(err).Msg("closed")
166 })185 })
167 return err186 return err
168 }187 }
169 188
170 // Snapshot returns the current immutable state. It is lock-free and O(1), and189 // Snapshot returns the current immutable state. It is lock-free and O(1), and
171-// remains usable after Close.190+// remains usable after Close. Like every read, it ignores ctx.
172-func (db *DB) Snapshot() *Snapshot { return db.snap.Load() }191+func (db *DB) Snapshot(_ context.Context) *Snapshot { return db.snap.Load() }
173 192
174 // Get returns the item stored under key in the current snapshot.193 // Get returns the item stored under key in the current snapshot.
175-func (db *DB) Get(key []byte) (Item, bool) { return db.snap.Load().Get(key) }194+func (db *DB) Get(ctx context.Context, key []byte) (Item, bool) {
195+ return db.snap.Load().Get(ctx, key)
196+}
176 197
177 // Apply commits b atomically and returns its seq once it is durable (unless198 // Apply commits b atomically and returns its seq once it is durable (unless
178 // Options.NoSync) and visible in Snapshot.199 // Options.NoSync) and visible in Snapshot.
@@ -193,7 +214,7 @@ func (db *DB) Apply(ctx context.Context, b *Batch) (uint64, error) {
193 if b.err != nil {214 if b.err != nil {
194 return 0, b.err215 return 0, b.err
195 }216 }
196- if b.payload() > db.opts.MaxBatchBytes {217+ if b.encodedSize() > db.opts.MaxBatchBytes {
197 return 0, ErrBatchTooLarge218 return 0, ErrBatchTooLarge
198 }219 }
199 select {220 select {
@@ -219,6 +240,6 @@ func (db *DB) Apply(ctx context.Context, b *Batch) (uint64, error) {
219 return 0, r.release(db.failErr)240 return 0, r.release(db.failErr)
220 }241 }
221 res := <-r.reply // the writer always answers a request it has taken242 res := <-r.reply // the writer always answers a request it has taken
222- r.release(nil)243+ _ = r.release(nil)
223 return res.seq, res.err244 return res.seq, res.err
224 }245 }
M db_test.go +129 -106
@@ -7,7 +7,6 @@ import (
77 "context"
88 "errors"
99 "fmt"
10- "log/slog"
1110 "math/rand/v2"
1211 "os"
1312 "path/filepath"
@@ -19,20 +18,38 @@ import (
1918 "testing"
2019 "time"
2120
21+ "github.com/bnema/zerowrap"
22+
2223 "bnema.dev/ermite-kv/internal/fsys"
2324 "bnema.dev/ermite-kv/internal/fsys/faultfs"
2425 )
2526
2627 var bg = context.Background()
2728
29+// testCtx returns a context carrying a console logger when
30+// ERMITE_KV_LOG_LEVEL is set (the library itself never reads the
31+// environment), and context.Background() (logging disabled) otherwise.
32+func testCtx(t testing.TB) context.Context {
33+ level := os.Getenv("ERMITE_KV_LOG_LEVEL")
34+ if level == "" {
35+ return bg
36+ }
37+ return zerowrap.WithCtx(bg, zerowrap.New(zerowrap.Config{Level: level, Format: "console", Output: t.Output()}))
38+}
39+
2840 // mustOpen opens a database on fx and closes it when the test ends.
2941 func mustOpen(t testing.TB, fx fsys.FS, opts Options) *DB {
3042 t.Helper()
31- db, err := open(fx, testDir, opts)
43+ return mustOpenCtx(t, testCtx(t), fx, opts)
44+}
45+
46+func mustOpenCtx(t testing.TB, ctx context.Context, fx fsys.FS, opts Options) *DB {
47+ t.Helper()
48+ db, err := open(ctx, fx, testDir, opts)
3249 if err != nil {
3350 t.Fatalf("open: %v", err)
3451 }
35- t.Cleanup(func() { _ = db.Close() })
52+ t.Cleanup(func() { _ = db.Close(bg) })
3653 return db
3754 }
3855
@@ -50,7 +67,7 @@ func putKV(t testing.TB, db *DB, key, value string) uint64 {
5067
5168 func mustGet(t testing.TB, s *Snapshot, key, want string) Item {
5269 t.Helper()
53- it, ok := s.Get([]byte(key))
70+ it, ok := s.Get(bg, []byte(key))
5471 if !ok || string(it.Value) != want {
5572 t.Fatalf("Get(%q) = %q, %v; want %q", key, it.Value, ok, want)
5673 }
@@ -59,7 +76,7 @@ func mustGet(t testing.TB, s *Snapshot, key, want string) Item {
5976
6077 func mustMissing(t testing.TB, s *Snapshot, key string) {
6178 t.Helper()
62- if it, ok := s.Get([]byte(key)); ok {
79+ if it, ok := s.Get(bg, []byte(key)); ok {
6380 t.Fatalf("Get(%q) = %q, want missing", key, it.Value)
6481 }
6582 }
@@ -74,7 +91,7 @@ func short(full, quick int) int {
7491 func TestReopenFaultfs(t *testing.T) {
7592 fx := faultfs.New()
7693 db := mustOpen(t, fx, Options{})
77- if s := db.Snapshot(); s.Seq() != 0 || s.Len() != 0 {
94+ if s := db.Snapshot(bg); s.Seq() != 0 || s.Len() != 0 {
7895 t.Fatalf("new db: seq %d len %d", s.Seq(), s.Len())
7996 }
8097 for i := range 50 {
@@ -89,15 +106,15 @@ func TestReopenFaultfs(t *testing.T) {
89106 if seq, err := db.Apply(bg, &b); err != nil || seq != 51 {
90107 t.Fatalf("Apply = %d, %v", seq, err)
91108 }
92- if err := db.Close(); err != nil {
109+ if err := db.Close(bg); err != nil {
93110 t.Fatalf("Close: %v", err)
94111 }
95- if err := db.Close(); err != nil {
112+ if err := db.Close(bg); err != nil {
96113 t.Fatalf("second Close: %v", err)
97114 }
98115
99116 db = mustOpen(t, fx, Options{})
100- s := db.Snapshot()
117+ s := db.Snapshot(bg)
101118 if s.Seq() != 51 || s.Len() != 49 {
102119 t.Fatalf("reopened: seq %d len %d, want 51 and 49", s.Seq(), s.Len())
103120 }
@@ -115,36 +132,36 @@ func TestReopenFaultfs(t *testing.T) {
115132
116133 func TestReopenOS(t *testing.T) {
117134 dir := filepath.Join(t.TempDir(), "sub", "db")
118- db, err := Open(dir, Options{})
135+ db, err := Open(testCtx(t), dir, Options{})
119136 if err != nil {
120137 t.Fatal(err)
121138 }
122139 for i := range 100 {
123140 putKV(t, db, fmt.Sprintf("k%03d", i), strings.Repeat("v", i))
124141 }
125- if err := db.Close(); err != nil {
142+ if err := db.Close(bg); err != nil {
126143 t.Fatal(err)
127144 }
128145 if fi, err := os.Stat(dir); err != nil || fi.Mode().Perm() != 0o700 {
129146 t.Fatalf("dir stat: %v, %v", fi, err)
130147 }
131148
132- db, err = Open(dir, Options{})
149+ db, err = Open(testCtx(t), dir, Options{})
133150 if err != nil {
134151 t.Fatal(err)
135152 }
136- defer db.Close()
137- s := db.Snapshot()
153+ defer func() { _ = db.Close(bg) }()
154+ s := db.Snapshot(bg)
138155 if s.Seq() != 100 || s.Len() != 100 {
139156 t.Fatalf("seq %d len %d, want 100", s.Seq(), s.Len())
140157 }
141158 for i := range 100 {
142159 mustGet(t, s, fmt.Sprintf("k%03d", i), strings.Repeat("v", i))
143160 }
144- if got := db.Snapshot(); got != s {
161+ if got := db.Snapshot(bg); got != s {
145162 t.Fatal("Snapshot changed without a write")
146163 }
147- if it, ok := db.Get([]byte("k005")); !ok || it.Version != 6 {
164+ if it, ok := db.Get(bg, []byte("k005")); !ok || it.Version != 6 {
148165 t.Fatalf("DB.Get = %v, %v", it, ok)
149166 }
150167 }
@@ -163,7 +180,7 @@ func TestOpenFromSnapshot(t *testing.T) {
163180 fx.WriteFile(filepath.Join(testDir, snapshotName(9)), buf.Bytes())
164181
165182 db := mustOpen(t, fx, Options{})
166- s := db.Snapshot()
183+ s := db.Snapshot(bg)
167184 if s.Seq() != 9 || s.Len() != 3 {
168185 t.Fatalf("seq %d len %d", s.Seq(), s.Len())
169186 }
@@ -173,14 +190,14 @@ func TestOpenFromSnapshot(t *testing.T) {
173190 if seq := putKV(t, db, "d", "4"); seq != 10 {
174191 t.Fatalf("seq = %d, want 10", seq)
175192 }
176- if err := db.Close(); err != nil {
193+ if err := db.Close(bg); err != nil {
177194 t.Fatal(err)
178195 }
179196 db = mustOpen(t, fx, Options{})
180- mustGet(t, db.Snapshot(), "d", "4")
181- mustGet(t, db.Snapshot(), "c", "3")
182- if db.Snapshot().Seq() != 10 {
183- t.Fatalf("seq = %d", db.Snapshot().Seq())
197+ mustGet(t, db.Snapshot(bg), "d", "4")
198+ mustGet(t, db.Snapshot(bg), "c", "3")
199+ if db.Snapshot(bg).Seq() != 10 {
200+ t.Fatalf("seq = %d", db.Snapshot(bg).Seq())
184201 }
185202 }
186203
@@ -205,7 +222,7 @@ func TestConcurrentWriters(t *testing.T) {
205222 t.Errorf("writer %d: %v", g, err)
206223 return
207224 }
208- if it, ok := db.Get([]byte(key)); !ok || it.Version < seq {
225+ if it, ok := db.Get(bg, []byte(key)); !ok || it.Version < seq {
209226 t.Errorf("writer %d: no read-your-writes for %s at seq %d", g, key, seq)
210227 return
211228 }
@@ -228,10 +245,10 @@ func TestConcurrentWriters(t *testing.T) {
228245 t.Fatalf("seq %d at rank %d: seqs are not 1..%d", s, i, len(all))
229246 }
230247 }
231- if got := db.Snapshot().Seq(); got != writers*uint64(per) {
248+ if got := db.Snapshot(bg).Seq(); got != writers*uint64(per) {
232249 t.Fatalf("Seq = %d, want %d", got, writers*per)
233250 }
234- if got := db.Snapshot().Len(); got != writers*per {
251+ if got := db.Snapshot(bg).Len(); got != writers*per {
235252 t.Fatalf("Len = %d, want %d", got, writers*per)
236253 }
237254 }
@@ -271,9 +288,9 @@ func TestRequireAbsentRace(t *testing.T) {
271288 t.Fatalf("%d winners, want 1", wins.Load())
272289 }
273290 w := slices.Index(winner, true)
274- mustGet(t, db.Snapshot(), "lock", strconv.Itoa(w))
275- if db.Snapshot().Seq() != 1 {
276- t.Fatalf("Seq = %d, want 1 (conflicts consume no seq)", db.Snapshot().Seq())
291+ mustGet(t, db.Snapshot(bg), "lock", strconv.Itoa(w))
292+ if db.Snapshot(bg).Seq() != 1 {
293+ t.Fatalf("Seq = %d, want 1 (conflicts consume no seq)", db.Snapshot(bg).Seq())
277294 }
278295 })
279296 }
@@ -298,9 +315,9 @@ func TestRequireVersion(t *testing.T) {
298315 if seq, err := cas("missing", 1, "c"); !errors.Is(err, ErrConflict) || seq != 0 {
299316 t.Fatalf("cas on missing key: %d, %v", seq, err)
300317 }
301- mustGet(t, db.Snapshot(), "k", "b")
302- mustMissing(t, db.Snapshot(), "missing")
303- if got := db.Snapshot().Seq(); got != v2 {
318+ mustGet(t, db.Snapshot(bg), "k", "b")
319+ mustMissing(t, db.Snapshot(bg), "missing")
320+ if got := db.Snapshot(bg).Seq(); got != v2 {
304321 t.Fatalf("Seq = %d, want %d", got, v2)
305322 }
306323 }
@@ -323,15 +340,15 @@ func TestPreconditionsBeforeOps(t *testing.T) {
323340 b.Put([]byte("other"), []byte("x"))
324341 b.RequireAbsent([]byte("never"))
325342 b.RequireAbsent([]byte("exists")) // fails
326- before := db.Snapshot()
343+ before := db.Snapshot(bg)
327344 if seq, err := db.Apply(bg, &b); !errors.Is(err, ErrConflict) || seq != 0 {
328345 t.Fatalf("Apply = %d, %v; want ErrConflict", seq, err)
329346 }
330- if db.Snapshot() != before {
347+ if db.Snapshot(bg) != before {
331348 t.Fatal("a conflicting batch published a snapshot")
332349 }
333- mustGet(t, db.Snapshot(), "exists", "1")
334- mustMissing(t, db.Snapshot(), "other")
350+ mustGet(t, db.Snapshot(bg), "exists", "1")
351+ mustMissing(t, db.Snapshot(bg), "other")
335352 }
336353
337354 // TestGroupPreconditions checks that preconditions see earlier batches of
@@ -367,7 +384,7 @@ func TestEmptyAndConflictOnlyBatches(t *testing.T) {
367384 putKV(t, db, "a", "1")
368385 putKV(t, db, "b", "2")
369386 writes, syncs := fx.Writes(), fx.Syncs()
370- snap := db.Snapshot()
387+ snap := db.Snapshot(bg)
371388
372389 var empty Batch
373390 if seq, err := db.Apply(bg, &empty); err != nil || seq != 2 {
@@ -403,15 +420,15 @@ func TestEmptyAndConflictOnlyBatches(t *testing.T) {
403420 if got := fx.Syncs() - syncs; got != 1 {
404421 t.Fatalf("%d syncs, want 1", got)
405422 }
406- if db.Snapshot().Len() != snap.Len() || db.Snapshot().Seq() != 3 {
407- t.Fatalf("snapshot after: seq %d len %d", db.Snapshot().Seq(), db.Snapshot().Len())
423+ if db.Snapshot(bg).Len() != snap.Len() || db.Snapshot(bg).Seq() != 3 {
424+ t.Fatalf("snapshot after: seq %d len %d", db.Snapshot(bg).Seq(), db.Snapshot(bg).Len())
408425 }
409- if err := db.Close(); err != nil {
426+ if err := db.Close(bg); err != nil {
410427 t.Fatal(err)
411428 }
412429 db = mustOpen(t, fx, Options{})
413- if db.Snapshot().Seq() != 3 || db.Snapshot().Len() != 2 {
414- t.Fatalf("reopened: seq %d len %d", db.Snapshot().Seq(), db.Snapshot().Len())
430+ if db.Snapshot(bg).Seq() != 3 || db.Snapshot(bg).Len() != 2 {
431+ t.Fatalf("reopened: seq %d len %d", db.Snapshot(bg).Seq(), db.Snapshot(bg).Len())
415432 }
416433 }
417434
@@ -461,7 +478,7 @@ func TestGroupCommitSyncs(t *testing.T) {
461478 if syncs >= total/4 {
462479 t.Fatalf("%d syncs for %d batches: no group commit", syncs, total)
463480 }
464- if got := db.Snapshot().Len(); got != total {
481+ if got := db.Snapshot(bg).Len(); got != total {
465482 t.Fatalf("Len = %d, want %d", got, total)
466483 }
467484 }
@@ -474,10 +491,10 @@ func TestNoSync(t *testing.T) {
474491 if fx.Syncs() != base {
475492 t.Fatal("NoSync still syncs")
476493 }
477- if err := db.Close(); err != nil {
494+ if err := db.Close(bg); err != nil {
478495 t.Fatal(err)
479496 }
480- mustGet(t, mustOpen(t, fx, Options{}).Snapshot(), "a", "1")
497+ mustGet(t, mustOpen(t, fx, Options{}).Snapshot(bg), "a", "1")
481498 }
482499
483500 // TestLargeBatches covers a batch above the 4 MiB group cap (a group of one)
@@ -502,10 +519,10 @@ func TestLargeBatches(t *testing.T) {
502519 t.Fatalf("seqs = %v", seqs)
503520 }
504521 }
505- if err := db.Close(); err != nil {
522+ if err := db.Close(bg); err != nil {
506523 t.Fatal(err)
507524 }
508- s := mustOpen(t, fx, Options{}).Snapshot()
525+ s := mustOpen(t, fx, Options{}).Snapshot(bg)
509526 mustGet(t, s, "big", big)
510527 for i := range n {
511528 mustGet(t, s, fmt.Sprintf("chunk%d", i), chunk)
@@ -532,48 +549,49 @@ func TestInvalidInputs(t *testing.T) {
532549 t.Fatalf("version 0: %v", err)
533550 }
534551
535- // Payload = 12 (seq, count) + 1 (kind) + 1 (klen) + 1 (key) + 1 (vlen) + v.
552+ // Encoded batch = 4 (count) + 1 (kind) + 1 (klen) + 1 (key) + 1 (vlen) + v.
536553 b.Reset()
537- b.Put([]byte("k"), make([]byte, 85))
554+ b.Put([]byte("k"), make([]byte, 93))
538555 if seq, err := db.Apply(bg, &b); !errors.Is(err, ErrBatchTooLarge) || seq != 0 {
539- t.Fatalf("101-byte payload: %d, %v", seq, err)
556+ t.Fatalf("101-byte batch: %d, %v", seq, err)
540557 }
541558 b.Reset()
542- b.Put([]byte("k"), make([]byte, 84))
559+ b.Put([]byte("k"), make([]byte, 92))
543560 if _, err := db.Apply(bg, &b); err != nil {
544- t.Fatalf("100-byte payload: %v", err)
561+ t.Fatalf("100-byte batch: %v", err)
545562 }
546- if db.Snapshot().Seq() != 1 {
547- t.Fatalf("Seq = %d: rejected batches reached the log", db.Snapshot().Seq())
563+ if db.Snapshot(bg).Seq() != 1 {
564+ t.Fatalf("Seq = %d: rejected batches reached the log", db.Snapshot(bg).Seq())
548565 }
549566 }
550567
551568 func TestOptionValidation(t *testing.T) {
552569 bad := []Options{
553570 {MaxBatchBytes: maxRecord + 1},
571+ {MaxBatchBytes: maxRecord - 11},
554572 {MaxBatchBytes: -1},
555573 {GroupWindow: -time.Second},
556574 }
557575 for _, o := range bad {
558576 fx := faultfs.New()
559- if db, err := open(fx, testDir, o); err == nil {
560- _ = db.Close()
577+ if db, err := open(testCtx(t), fx, testDir, o); err == nil {
578+ _ = db.Close(bg)
561579 t.Errorf("open(%+v) succeeded", o)
562580 }
563581 if _, err := fx.ReadDir(testDir); err == nil {
564582 t.Errorf("open(%+v) created the directory", o)
565583 }
566584 }
567- if _, err := Open(t.TempDir(), Options{MaxBatchBytes: 65 << 20}); err == nil {
585+ if _, err := Open(testCtx(t), t.TempDir(), Options{MaxBatchBytes: 65 << 20}); err == nil {
568586 t.Error("Open with MaxBatchBytes > 64 MiB succeeded")
569587 }
570- for _, o := range []Options{{MaxBatchBytes: maxRecord}, {CompactAfter: -1}, {NoSync: true, CompactAfter: 1}} {
571- db, err := open(faultfs.New(), testDir, o)
588+ for _, o := range []Options{{MaxBatchBytes: maxRecord - 12}, {CompactAfter: -1}, {NoSync: true, CompactAfter: 1}} {
589+ db, err := open(testCtx(t), faultfs.New(), testDir, o)
572590 if err != nil {
573591 t.Errorf("open(%+v): %v", o, err)
574592 continue
575593 }
576- _ = db.Close()
594+ _ = db.Close(bg)
577595 }
578596 }
579597
@@ -581,39 +599,39 @@ func TestLocked(t *testing.T) {
581599 t.Run("faultfs", func(t *testing.T) {
582600 fx := faultfs.New()
583601 db := mustOpen(t, fx, Options{})
584- if _, err := open(fx, testDir, Options{}); !errors.Is(err, ErrLocked) {
602+ if _, err := open(testCtx(t), fx, testDir, Options{}); !errors.Is(err, ErrLocked) {
585603 t.Fatalf("second open: %v, want ErrLocked", err)
586604 }
587- if err := db.Close(); err != nil {
605+ if err := db.Close(bg); err != nil {
588606 t.Fatal(err)
589607 }
590608 mustOpen(t, fx, Options{})
591609 })
592610 t.Run("os", func(t *testing.T) {
593611 dir := t.TempDir()
594- db, err := Open(dir, Options{})
612+ db, err := Open(testCtx(t), dir, Options{})
595613 if err != nil {
596614 t.Fatal(err)
597615 }
598- if _, err := Open(dir, Options{}); !errors.Is(err, ErrLocked) {
616+ if _, err := Open(testCtx(t), dir, Options{}); !errors.Is(err, ErrLocked) {
599617 t.Fatalf("second Open: %v, want ErrLocked", err)
600618 }
601619 putKV(t, db, "still", "works")
602- if err := db.Close(); err != nil {
620+ if err := db.Close(bg); err != nil {
603621 t.Fatal(err)
604622 }
605- db, err = Open(dir, Options{})
623+ db, err = Open(testCtx(t), dir, Options{})
606624 if err != nil {
607625 t.Fatalf("Open after Close: %v", err)
608626 }
609- db.Close()
627+ _ = db.Close(bg)
610628 })
611629 }
612630
613631 func TestApplyAfterClose(t *testing.T) {
614632 db := mustOpen(t, faultfs.New(), Options{})
615633 putKV(t, db, "a", "1")
616- if err := db.Close(); err != nil {
634+ if err := db.Close(bg); err != nil {
617635 t.Fatal(err)
618636 }
619637 var b Batch
@@ -621,8 +639,8 @@ func TestApplyAfterClose(t *testing.T) {
621639 if _, err := db.Apply(bg, &b); !errors.Is(err, ErrClosed) {
622640 t.Fatalf("Apply after Close: %v", err)
623641 }
624- mustGet(t, db.Snapshot(), "a", "1") // reads keep working
625- if err := db.Close(); err != nil {
642+ mustGet(t, db.Snapshot(bg), "a", "1") // reads keep working
643+ if err := db.Close(bg); err != nil {
626644 t.Fatalf("second Close: %v", err)
627645 }
628646 }
@@ -671,10 +689,10 @@ func TestContextBeforeDequeue(t *testing.T) {
671689 t.Fatalf("Apply with cancelled ctx = %v", err)
672690 }
673691 putKV(t, db, "fourth", "4") // the writer is still healthy
674- if err := db.Close(); err != nil {
692+ if err := db.Close(bg); err != nil {
675693 t.Fatal(err)
676694 }
677- s := mustOpen(t, fx, Options{}).Snapshot()
695+ s := mustOpen(t, fx, Options{}).Snapshot(bg)
678696 mustGet(t, s, "first", "1")
679697 mustGet(t, s, "fourth", "4")
680698 mustMissing(t, s, "second")
@@ -712,7 +730,7 @@ func TestCloseDuringApplies(t *testing.T) {
712730 }
713731 time.Sleep(time.Duration(2+i) * time.Millisecond)
714732 closed := make(chan error, 1)
715- go func() { closed <- db.Close() }()
733+ go func() { closed <- db.Close(bg) }()
716734 select {
717735 case err := <-closed:
718736 if err != nil {
@@ -724,12 +742,12 @@ func TestCloseDuringApplies(t *testing.T) {
724742 wg.Wait()
725743
726744 fx.SetSyncDelay(0)
727- s := mustOpen(t, fx, Options{}).Snapshot()
745+ s := mustOpen(t, fx, Options{}).Snapshot(bg)
728746 for g, n := range acked {
729747 if n == 0 {
730748 continue
731749 }
732- it, ok := s.Get(fmt.Appendf(nil, "g%02d", g))
750+ it, ok := s.Get(bg, fmt.Appendf(nil, "g%02d", g))
733751 got, _ := strconv.Atoi(string(it.Value))
734752 // The batch after the last acknowledged one may have been
735753 // committed by the final group.
@@ -767,8 +785,9 @@ func TestInjectedFailures(t *testing.T) {
767785
768786 func testInjectedFailure(t *testing.T, f failure, powerLoss bool) {
769787 fx := faultfs.New()
770- logger, logs := captureLogger()
771- db := mustOpen(t, fx, Options{GroupWindow: 150 * time.Millisecond, Logger: logger})
788+ var logs syncBuffer
789+ ctx := zerowrap.WithCtx(bg, zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
790+ db := mustOpenCtx(t, ctx, fx, Options{GroupWindow: 150 * time.Millisecond})
772791 putKV(t, db, "before", "1")
773792 f.inject(fx)
774793
@@ -792,7 +811,7 @@ func testInjectedFailure(t *testing.T, f failure, powerLoss bool) {
792811 t.Fatalf("batch %d: %v does not wrap %v", i, err, f.cause)
793812 }
794813 }
795- if !strings.Contains(logs.String(), "database failed") {
814+ if !strings.Contains(logs.String(), `"level":"error"`) || !strings.Contains(logs.String(), "database failed") {
796815 t.Errorf("failure not logged: %q", logs.String())
797816 }
798817
@@ -808,27 +827,27 @@ func testInjectedFailure(t *testing.T, f failure, powerLoss bool) {
808827 if _, err := db.Apply(bg, &empty); !errors.Is(err, ErrFailed) {
809828 t.Fatalf("empty Apply on a failed db: %v", err)
810829 }
811- s := db.Snapshot()
830+ s := db.Snapshot(bg)
812831 if s.Seq() != 1 || s.Len() != 1 {
813832 t.Fatalf("snapshot after failure: seq %d len %d", s.Seq(), s.Len())
814833 }
815834 mustGet(t, s, "before", "1")
816835 mustMissing(t, s, "failed0")
817- if err := db.Close(); !errors.Is(err, ErrFailed) {
836+ if err := db.Close(bg); !errors.Is(err, ErrFailed) {
818837 t.Fatalf("Close = %v, want the failure", err)
819838 }
820- if err := db.Close(); err != nil {
839+ if err := db.Close(bg); err != nil {
821840 t.Fatalf("second Close = %v", err)
822841 }
823842
824843 if powerLoss {
825844 fx.PowerLoss(faultfs.PowerLossOptions{})
826845 }
827- s = mustOpen(t, fx, Options{}).Snapshot()
846+ s = mustOpen(t, fx, Options{}).Snapshot(bg)
828847 mustGet(t, s, "before", "1")
829848 present := 0
830849 for i := range group {
831- if _, ok := s.Get(fmt.Appendf(nil, "failed%d", i)); ok {
850+ if _, ok := s.Get(bg, fmt.Appendf(nil, "failed%d", i)); ok {
832851 present++
833852 }
834853 }
@@ -868,12 +887,6 @@ func TestFailedWriterAnswersOverflow(t *testing.T) {
868887 }
869888 }
870889
871-// captureLogger returns a logger writing to a buffer.
872-func captureLogger() (*slog.Logger, *syncBuffer) {
873- var sb syncBuffer
874- return slog.New(slog.NewTextHandler(&sb, nil)), &sb
875-}
876-
877890 type syncBuffer struct {
878891 mu sync.Mutex
879892 b strings.Builder
@@ -910,8 +923,10 @@ func powerLossBase(t *testing.T) uint64 {
910923 // Every writer g applies batches n = 0, 1, … that set cur/g = n and
911924 // all/g/n, and delete all/g/(n-1). The recovered state therefore says how
912925 // many batches of each writer were applied, and the seq must equal their sum.
913-// (PowerLossOptions.KeepPages is not exercised: see the open question about
914-// recovery rule 6 under out-of-order page writeback.)
926+// The modes include KeepPages (a random subset of the unsynced pages
927+// survives). They always run with NoSync=false: with NoSync, KeepPages can
928+// legitimately make Open fail with ErrCorrupt, which is documented on
929+// Options.NoSync and not tested.
915930 func TestPowerLoss(t *testing.T) {
916931 modes := []struct {
917932 name string
@@ -919,6 +934,10 @@ func TestPowerLoss(t *testing.T) {
919934 }{
920935 {"drop", faultfs.PowerLossOptions{}},
921936 {"zerofill", faultfs.PowerLossOptions{ZeroFill: true}},
937+ // Pages of the unsynced tail reach the disk in any order. One record
938+ // per group makes this safe when commits are synced.
939+ {"keeppages", faultfs.PowerLossOptions{KeepPages: true}},
940+ {"keeppages+zerofill", faultfs.PowerLossOptions{KeepPages: true, ZeroFill: true}},
922941 }
923942 base := powerLossBase(t)
924943 iterations := short(150, 10)
@@ -937,7 +956,7 @@ func TestPowerLoss(t *testing.T) {
937956 func runPowerLoss(t *testing.T, seed uint64, plo faultfs.PowerLossOptions) (ok bool) {
938957 rng := rand.New(rand.NewPCG(seed, 0x9e3779b97f4a7c15))
939958 fx := faultfs.New()
940- db, err := open(fx, testDir, Options{GroupWindow: time.Duration(rng.IntN(3)) * 100 * time.Microsecond})
959+ db, err := open(testCtx(t), fx, testDir, Options{GroupWindow: time.Duration(rng.IntN(3)) * 100 * time.Microsecond})
941960 if err != nil {
942961 t.Errorf("seed %d: open: %v", seed, err)
943962 return false
@@ -966,39 +985,43 @@ func runPowerLoss(t *testing.T, seed uint64, plo faultfs.PowerLossOptions) (ok b
966985 })
967986 }
968987 time.Sleep(time.Duration(rng.IntN(4000)) * time.Microsecond)
988+ plo.Rand = rand.New(rand.NewPCG(seed, 7))
989+ if plo.KeepPages {
990+ plo.PageSize = 64 // small pages: a group record spans several of them
991+ }
969992 fx.PowerLoss(plo)
970993 wg.Wait()
971- _ = db.Close()
994+ _ = db.Close(bg)
972995 fx.SetSyncDelay(0)
973996
974- db, err = open(fx, testDir, Options{})
997+ db, err = open(testCtx(t), fx, testDir, Options{})
975998 if err != nil {
976999 t.Errorf("seed %d: reopen after power loss: %v", seed, err)
9771000 return false
9781001 }
979- defer db.Close()
980- if !checkPowerLossState(t, seed, db.Snapshot(), acked, ackSeq) {
1002+ defer func() { _ = db.Close(bg) }()
1003+ if !checkPowerLossState(t, seed, db.Snapshot(bg), acked, ackSeq) {
9811004 return false
9821005 }
9831006
9841007 // The recovered log accepts new records and survives another reopen.
985- seq := db.Snapshot().Seq()
1008+ seq := db.Snapshot(bg).Seq()
9861009 if got := putKV(t, db, "after", "x"); got != seq+1 {
9871010 t.Errorf("seed %d: seq after recovery = %d, want %d", seed, got, seq+1)
9881011 return false
9891012 }
990- if err := db.Close(); err != nil {
1013+ if err := db.Close(bg); err != nil {
9911014 t.Errorf("seed %d: Close: %v", seed, err)
9921015 return false
9931016 }
994- db, err = open(fx, testDir, Options{})
1017+ db, err = open(testCtx(t), fx, testDir, Options{})
9951018 if err != nil {
9961019 t.Errorf("seed %d: second reopen: %v", seed, err)
9971020 return false
9981021 }
999- defer db.Close()
1000- if db.Snapshot().Seq() != seq+1 {
1001- t.Errorf("seed %d: seq after second reopen = %d, want %d", seed, db.Snapshot().Seq(), seq+1)
1022+ defer func() { _ = db.Close(bg) }()
1023+ if db.Snapshot(bg).Seq() != seq+1 {
1024+ t.Errorf("seed %d: seq after second reopen = %d, want %d", seed, db.Snapshot(bg).Seq(), seq+1)
10021025 return false
10031026 }
10041027 return true
@@ -1012,7 +1035,7 @@ func checkPowerLossState(t *testing.T, seed uint64, s *Snapshot, acked []int, ac
10121035 var applied uint64
10131036 keys := 0
10141037 for g := range acked {
1015- it, ok := s.Get(fmt.Appendf(nil, "cur/%d", g))
1038+ it, ok := s.Get(bg, fmt.Appendf(nil, "cur/%d", g))
10161039 last := -1
10171040 if ok {
10181041 n, err := strconv.Atoi(string(it.Value))
@@ -1030,12 +1053,12 @@ func checkPowerLossState(t *testing.T, seed uint64, s *Snapshot, acked []int, ac
10301053 if !ok {
10311054 continue
10321055 }
1033- cur, ok := s.Get(fmt.Appendf(nil, "all/%d/%d", g, last))
1056+ cur, ok := s.Get(bg, fmt.Appendf(nil, "all/%d/%d", g, last))
10341057 if !ok || cur.Version != it.Version {
10351058 return fail("writer %d: batch %d is partially applied (%v, %v)", g, last, cur, ok)
10361059 }
10371060 count := 0
1038- for range s.Prefix(fmt.Appendf(nil, "all/%d/", g)) {
1061+ for range s.Prefix(bg, fmt.Appendf(nil, "all/%d/", g)) {
10391062 count++
10401063 }
10411064 if count != 1 {
@@ -1082,11 +1105,11 @@ func (f countFile) Sync() error {
10821105 // around the OS file system, so the numbers are real fdatasync calls.
10831106 func BenchmarkApply(b *testing.B) {
10841107 fx := &countFS{FS: fsys.OS()}
1085- db, err := open(fx, filepath.Join(b.TempDir(), "db"), Options{})
1108+ db, err := open(bg, fx, filepath.Join(b.TempDir(), "db"), Options{})
10861109 if err != nil {
10871110 b.Fatal(err)
10881111 }
1089- defer db.Close()
1112+ defer func() { _ = db.Close(bg) }()
10901113 b.SetParallelism(16)
10911114 value := bytes.Repeat([]byte("v"), 100)
10921115 var next atomic.Uint64
@@ -7,7 +7,6 @@ import (
7 "context"7 "context"
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10- "log/slog"
11 "math/rand/v2"10 "math/rand/v2"
12 "os"11 "os"
13 "path/filepath"12 "path/filepath"
@@ -19,20 +18,38 @@ import (
19 "testing"18 "testing"
20 "time"19 "time"
21 20
21+ "github.com/bnema/zerowrap"
22+
22 "bnema.dev/ermite-kv/internal/fsys"23 "bnema.dev/ermite-kv/internal/fsys"
23 "bnema.dev/ermite-kv/internal/fsys/faultfs"24 "bnema.dev/ermite-kv/internal/fsys/faultfs"
24 )25 )
25 26
26 var bg = context.Background()27 var bg = context.Background()
27 28
29+// testCtx returns a context carrying a console logger when
30+// ERMITE_KV_LOG_LEVEL is set (the library itself never reads the
31+// environment), and context.Background() (logging disabled) otherwise.
32+func testCtx(t testing.TB) context.Context {
33+ level := os.Getenv("ERMITE_KV_LOG_LEVEL")
34+ if level == "" {
35+ return bg
36+ }
37+ return zerowrap.WithCtx(bg, zerowrap.New(zerowrap.Config{Level: level, Format: "console", Output: t.Output()}))
38+}
39+
28 // mustOpen opens a database on fx and closes it when the test ends.40 // mustOpen opens a database on fx and closes it when the test ends.
29 func mustOpen(t testing.TB, fx fsys.FS, opts Options) *DB {41 func mustOpen(t testing.TB, fx fsys.FS, opts Options) *DB {
30 t.Helper()42 t.Helper()
31- db, err := open(fx, testDir, opts)43+ return mustOpenCtx(t, testCtx(t), fx, opts)
44+}
45+
46+func mustOpenCtx(t testing.TB, ctx context.Context, fx fsys.FS, opts Options) *DB {
47+ t.Helper()
48+ db, err := open(ctx, fx, testDir, opts)
32 if err != nil {49 if err != nil {
33 t.Fatalf("open: %v", err)50 t.Fatalf("open: %v", err)
34 }51 }
35- t.Cleanup(func() { _ = db.Close() })52+ t.Cleanup(func() { _ = db.Close(bg) })
36 return db53 return db
37 }54 }
38 55
@@ -50,7 +67,7 @@ func putKV(t testing.TB, db *DB, key, value string) uint64 {
50 67
51 func mustGet(t testing.TB, s *Snapshot, key, want string) Item {68 func mustGet(t testing.TB, s *Snapshot, key, want string) Item {
52 t.Helper()69 t.Helper()
53- it, ok := s.Get([]byte(key))70+ it, ok := s.Get(bg, []byte(key))
54 if !ok || string(it.Value) != want {71 if !ok || string(it.Value) != want {
55 t.Fatalf("Get(%q) = %q, %v; want %q", key, it.Value, ok, want)72 t.Fatalf("Get(%q) = %q, %v; want %q", key, it.Value, ok, want)
56 }73 }
@@ -59,7 +76,7 @@ func mustGet(t testing.TB, s *Snapshot, key, want string) Item {
59 76
60 func mustMissing(t testing.TB, s *Snapshot, key string) {77 func mustMissing(t testing.TB, s *Snapshot, key string) {
61 t.Helper()78 t.Helper()
62- if it, ok := s.Get([]byte(key)); ok {79+ if it, ok := s.Get(bg, []byte(key)); ok {
63 t.Fatalf("Get(%q) = %q, want missing", key, it.Value)80 t.Fatalf("Get(%q) = %q, want missing", key, it.Value)
64 }81 }
65 }82 }
@@ -74,7 +91,7 @@ func short(full, quick int) int {
74 func TestReopenFaultfs(t *testing.T) {91 func TestReopenFaultfs(t *testing.T) {
75 fx := faultfs.New()92 fx := faultfs.New()
76 db := mustOpen(t, fx, Options{})93 db := mustOpen(t, fx, Options{})
77- if s := db.Snapshot(); s.Seq() != 0 || s.Len() != 0 {94+ if s := db.Snapshot(bg); s.Seq() != 0 || s.Len() != 0 {
78 t.Fatalf("new db: seq %d len %d", s.Seq(), s.Len())95 t.Fatalf("new db: seq %d len %d", s.Seq(), s.Len())
79 }96 }
80 for i := range 50 {97 for i := range 50 {
@@ -89,15 +106,15 @@ func TestReopenFaultfs(t *testing.T) {
89 if seq, err := db.Apply(bg, &b); err != nil || seq != 51 {106 if seq, err := db.Apply(bg, &b); err != nil || seq != 51 {
90 t.Fatalf("Apply = %d, %v", seq, err)107 t.Fatalf("Apply = %d, %v", seq, err)
91 }108 }
92- if err := db.Close(); err != nil {109+ if err := db.Close(bg); err != nil {
93 t.Fatalf("Close: %v", err)110 t.Fatalf("Close: %v", err)
94 }111 }
95- if err := db.Close(); err != nil {112+ if err := db.Close(bg); err != nil {
96 t.Fatalf("second Close: %v", err)113 t.Fatalf("second Close: %v", err)
97 }114 }
98 115
99 db = mustOpen(t, fx, Options{})116 db = mustOpen(t, fx, Options{})
100- s := db.Snapshot()117+ s := db.Snapshot(bg)
101 if s.Seq() != 51 || s.Len() != 49 {118 if s.Seq() != 51 || s.Len() != 49 {
102 t.Fatalf("reopened: seq %d len %d, want 51 and 49", s.Seq(), s.Len())119 t.Fatalf("reopened: seq %d len %d, want 51 and 49", s.Seq(), s.Len())
103 }120 }
@@ -115,36 +132,36 @@ func TestReopenFaultfs(t *testing.T) {
115 132
116 func TestReopenOS(t *testing.T) {133 func TestReopenOS(t *testing.T) {
117 dir := filepath.Join(t.TempDir(), "sub", "db")134 dir := filepath.Join(t.TempDir(), "sub", "db")
118- db, err := Open(dir, Options{})135+ db, err := Open(testCtx(t), dir, Options{})
119 if err != nil {136 if err != nil {
120 t.Fatal(err)137 t.Fatal(err)
121 }138 }
122 for i := range 100 {139 for i := range 100 {
123 putKV(t, db, fmt.Sprintf("k%03d", i), strings.Repeat("v", i))140 putKV(t, db, fmt.Sprintf("k%03d", i), strings.Repeat("v", i))
124 }141 }
125- if err := db.Close(); err != nil {142+ if err := db.Close(bg); err != nil {
126 t.Fatal(err)143 t.Fatal(err)
127 }144 }
128 if fi, err := os.Stat(dir); err != nil || fi.Mode().Perm() != 0o700 {145 if fi, err := os.Stat(dir); err != nil || fi.Mode().Perm() != 0o700 {
129 t.Fatalf("dir stat: %v, %v", fi, err)146 t.Fatalf("dir stat: %v, %v", fi, err)
130 }147 }
131 148
132- db, err = Open(dir, Options{})149+ db, err = Open(testCtx(t), dir, Options{})
133 if err != nil {150 if err != nil {
134 t.Fatal(err)151 t.Fatal(err)
135 }152 }
136- defer db.Close()153+ defer func() { _ = db.Close(bg) }()
137- s := db.Snapshot()154+ s := db.Snapshot(bg)
138 if s.Seq() != 100 || s.Len() != 100 {155 if s.Seq() != 100 || s.Len() != 100 {
139 t.Fatalf("seq %d len %d, want 100", s.Seq(), s.Len())156 t.Fatalf("seq %d len %d, want 100", s.Seq(), s.Len())
140 }157 }
141 for i := range 100 {158 for i := range 100 {
142 mustGet(t, s, fmt.Sprintf("k%03d", i), strings.Repeat("v", i))159 mustGet(t, s, fmt.Sprintf("k%03d", i), strings.Repeat("v", i))
143 }160 }
144- if got := db.Snapshot(); got != s {161+ if got := db.Snapshot(bg); got != s {
145 t.Fatal("Snapshot changed without a write")162 t.Fatal("Snapshot changed without a write")
146 }163 }
147- if it, ok := db.Get([]byte("k005")); !ok || it.Version != 6 {164+ if it, ok := db.Get(bg, []byte("k005")); !ok || it.Version != 6 {
148 t.Fatalf("DB.Get = %v, %v", it, ok)165 t.Fatalf("DB.Get = %v, %v", it, ok)
149 }166 }
150 }167 }
@@ -163,7 +180,7 @@ func TestOpenFromSnapshot(t *testing.T) {
163 fx.WriteFile(filepath.Join(testDir, snapshotName(9)), buf.Bytes())180 fx.WriteFile(filepath.Join(testDir, snapshotName(9)), buf.Bytes())
164 181
165 db := mustOpen(t, fx, Options{})182 db := mustOpen(t, fx, Options{})
166- s := db.Snapshot()183+ s := db.Snapshot(bg)
167 if s.Seq() != 9 || s.Len() != 3 {184 if s.Seq() != 9 || s.Len() != 3 {
168 t.Fatalf("seq %d len %d", s.Seq(), s.Len())185 t.Fatalf("seq %d len %d", s.Seq(), s.Len())
169 }186 }
@@ -173,14 +190,14 @@ func TestOpenFromSnapshot(t *testing.T) {
173 if seq := putKV(t, db, "d", "4"); seq != 10 {190 if seq := putKV(t, db, "d", "4"); seq != 10 {
174 t.Fatalf("seq = %d, want 10", seq)191 t.Fatalf("seq = %d, want 10", seq)
175 }192 }
176- if err := db.Close(); err != nil {193+ if err := db.Close(bg); err != nil {
177 t.Fatal(err)194 t.Fatal(err)
178 }195 }
179 db = mustOpen(t, fx, Options{})196 db = mustOpen(t, fx, Options{})
180- mustGet(t, db.Snapshot(), "d", "4")197+ mustGet(t, db.Snapshot(bg), "d", "4")
181- mustGet(t, db.Snapshot(), "c", "3")198+ mustGet(t, db.Snapshot(bg), "c", "3")
182- if db.Snapshot().Seq() != 10 {199+ if db.Snapshot(bg).Seq() != 10 {
183- t.Fatalf("seq = %d", db.Snapshot().Seq())200+ t.Fatalf("seq = %d", db.Snapshot(bg).Seq())
184 }201 }
185 }202 }
186 203
@@ -205,7 +222,7 @@ func TestConcurrentWriters(t *testing.T) {
205 t.Errorf("writer %d: %v", g, err)222 t.Errorf("writer %d: %v", g, err)
206 return223 return
207 }224 }
208- if it, ok := db.Get([]byte(key)); !ok || it.Version < seq {225+ if it, ok := db.Get(bg, []byte(key)); !ok || it.Version < seq {
209 t.Errorf("writer %d: no read-your-writes for %s at seq %d", g, key, seq)226 t.Errorf("writer %d: no read-your-writes for %s at seq %d", g, key, seq)
210 return227 return
211 }228 }
@@ -228,10 +245,10 @@ func TestConcurrentWriters(t *testing.T) {
228 t.Fatalf("seq %d at rank %d: seqs are not 1..%d", s, i, len(all))245 t.Fatalf("seq %d at rank %d: seqs are not 1..%d", s, i, len(all))
229 }246 }
230 }247 }
231- if got := db.Snapshot().Seq(); got != writers*uint64(per) {248+ if got := db.Snapshot(bg).Seq(); got != writers*uint64(per) {
232 t.Fatalf("Seq = %d, want %d", got, writers*per)249 t.Fatalf("Seq = %d, want %d", got, writers*per)
233 }250 }
234- if got := db.Snapshot().Len(); got != writers*per {251+ if got := db.Snapshot(bg).Len(); got != writers*per {
235 t.Fatalf("Len = %d, want %d", got, writers*per)252 t.Fatalf("Len = %d, want %d", got, writers*per)
236 }253 }
237 }254 }
@@ -271,9 +288,9 @@ func TestRequireAbsentRace(t *testing.T) {
271 t.Fatalf("%d winners, want 1", wins.Load())288 t.Fatalf("%d winners, want 1", wins.Load())
272 }289 }
273 w := slices.Index(winner, true)290 w := slices.Index(winner, true)
274- mustGet(t, db.Snapshot(), "lock", strconv.Itoa(w))291+ mustGet(t, db.Snapshot(bg), "lock", strconv.Itoa(w))
275- if db.Snapshot().Seq() != 1 {292+ if db.Snapshot(bg).Seq() != 1 {
276- t.Fatalf("Seq = %d, want 1 (conflicts consume no seq)", db.Snapshot().Seq())293+ t.Fatalf("Seq = %d, want 1 (conflicts consume no seq)", db.Snapshot(bg).Seq())
277 }294 }
278 })295 })
279 }296 }
@@ -298,9 +315,9 @@ func TestRequireVersion(t *testing.T) {
298 if seq, err := cas("missing", 1, "c"); !errors.Is(err, ErrConflict) || seq != 0 {315 if seq, err := cas("missing", 1, "c"); !errors.Is(err, ErrConflict) || seq != 0 {
299 t.Fatalf("cas on missing key: %d, %v", seq, err)316 t.Fatalf("cas on missing key: %d, %v", seq, err)
300 }317 }
301- mustGet(t, db.Snapshot(), "k", "b")318+ mustGet(t, db.Snapshot(bg), "k", "b")
302- mustMissing(t, db.Snapshot(), "missing")319+ mustMissing(t, db.Snapshot(bg), "missing")
303- if got := db.Snapshot().Seq(); got != v2 {320+ if got := db.Snapshot(bg).Seq(); got != v2 {
304 t.Fatalf("Seq = %d, want %d", got, v2)321 t.Fatalf("Seq = %d, want %d", got, v2)
305 }322 }
306 }323 }
@@ -323,15 +340,15 @@ func TestPreconditionsBeforeOps(t *testing.T) {
323 b.Put([]byte("other"), []byte("x"))340 b.Put([]byte("other"), []byte("x"))
324 b.RequireAbsent([]byte("never"))341 b.RequireAbsent([]byte("never"))
325 b.RequireAbsent([]byte("exists")) // fails342 b.RequireAbsent([]byte("exists")) // fails
326- before := db.Snapshot()343+ before := db.Snapshot(bg)
327 if seq, err := db.Apply(bg, &b); !errors.Is(err, ErrConflict) || seq != 0 {344 if seq, err := db.Apply(bg, &b); !errors.Is(err, ErrConflict) || seq != 0 {
328 t.Fatalf("Apply = %d, %v; want ErrConflict", seq, err)345 t.Fatalf("Apply = %d, %v; want ErrConflict", seq, err)
329 }346 }
330- if db.Snapshot() != before {347+ if db.Snapshot(bg) != before {
331 t.Fatal("a conflicting batch published a snapshot")348 t.Fatal("a conflicting batch published a snapshot")
332 }349 }
333- mustGet(t, db.Snapshot(), "exists", "1")350+ mustGet(t, db.Snapshot(bg), "exists", "1")
334- mustMissing(t, db.Snapshot(), "other")351+ mustMissing(t, db.Snapshot(bg), "other")
335 }352 }
336 353
337 // TestGroupPreconditions checks that preconditions see earlier batches of354 // TestGroupPreconditions checks that preconditions see earlier batches of
@@ -367,7 +384,7 @@ func TestEmptyAndConflictOnlyBatches(t *testing.T) {
367 putKV(t, db, "a", "1")384 putKV(t, db, "a", "1")
368 putKV(t, db, "b", "2")385 putKV(t, db, "b", "2")
369 writes, syncs := fx.Writes(), fx.Syncs()386 writes, syncs := fx.Writes(), fx.Syncs()
370- snap := db.Snapshot()387+ snap := db.Snapshot(bg)
371 388
372 var empty Batch389 var empty Batch
373 if seq, err := db.Apply(bg, &empty); err != nil || seq != 2 {390 if seq, err := db.Apply(bg, &empty); err != nil || seq != 2 {
@@ -403,15 +420,15 @@ func TestEmptyAndConflictOnlyBatches(t *testing.T) {
403 if got := fx.Syncs() - syncs; got != 1 {420 if got := fx.Syncs() - syncs; got != 1 {
404 t.Fatalf("%d syncs, want 1", got)421 t.Fatalf("%d syncs, want 1", got)
405 }422 }
406- if db.Snapshot().Len() != snap.Len() || db.Snapshot().Seq() != 3 {423+ if db.Snapshot(bg).Len() != snap.Len() || db.Snapshot(bg).Seq() != 3 {
407- t.Fatalf("snapshot after: seq %d len %d", db.Snapshot().Seq(), db.Snapshot().Len())424+ t.Fatalf("snapshot after: seq %d len %d", db.Snapshot(bg).Seq(), db.Snapshot(bg).Len())
408 }425 }
409- if err := db.Close(); err != nil {426+ if err := db.Close(bg); err != nil {
410 t.Fatal(err)427 t.Fatal(err)
411 }428 }
412 db = mustOpen(t, fx, Options{})429 db = mustOpen(t, fx, Options{})
413- if db.Snapshot().Seq() != 3 || db.Snapshot().Len() != 2 {430+ if db.Snapshot(bg).Seq() != 3 || db.Snapshot(bg).Len() != 2 {
414- t.Fatalf("reopened: seq %d len %d", db.Snapshot().Seq(), db.Snapshot().Len())431+ t.Fatalf("reopened: seq %d len %d", db.Snapshot(bg).Seq(), db.Snapshot(bg).Len())
415 }432 }
416 }433 }
417 434
@@ -461,7 +478,7 @@ func TestGroupCommitSyncs(t *testing.T) {
461 if syncs >= total/4 {478 if syncs >= total/4 {
462 t.Fatalf("%d syncs for %d batches: no group commit", syncs, total)479 t.Fatalf("%d syncs for %d batches: no group commit", syncs, total)
463 }480 }
464- if got := db.Snapshot().Len(); got != total {481+ if got := db.Snapshot(bg).Len(); got != total {
465 t.Fatalf("Len = %d, want %d", got, total)482 t.Fatalf("Len = %d, want %d", got, total)
466 }483 }
467 }484 }
@@ -474,10 +491,10 @@ func TestNoSync(t *testing.T) {
474 if fx.Syncs() != base {491 if fx.Syncs() != base {
475 t.Fatal("NoSync still syncs")492 t.Fatal("NoSync still syncs")
476 }493 }
477- if err := db.Close(); err != nil {494+ if err := db.Close(bg); err != nil {
478 t.Fatal(err)495 t.Fatal(err)
479 }496 }
480- mustGet(t, mustOpen(t, fx, Options{}).Snapshot(), "a", "1")497+ mustGet(t, mustOpen(t, fx, Options{}).Snapshot(bg), "a", "1")
481 }498 }
482 499
483 // TestLargeBatches covers a batch above the 4 MiB group cap (a group of one)500 // TestLargeBatches covers a batch above the 4 MiB group cap (a group of one)
@@ -502,10 +519,10 @@ func TestLargeBatches(t *testing.T) {
502 t.Fatalf("seqs = %v", seqs)519 t.Fatalf("seqs = %v", seqs)
503 }520 }
504 }521 }
505- if err := db.Close(); err != nil {522+ if err := db.Close(bg); err != nil {
506 t.Fatal(err)523 t.Fatal(err)
507 }524 }
508- s := mustOpen(t, fx, Options{}).Snapshot()525+ s := mustOpen(t, fx, Options{}).Snapshot(bg)
509 mustGet(t, s, "big", big)526 mustGet(t, s, "big", big)
510 for i := range n {527 for i := range n {
511 mustGet(t, s, fmt.Sprintf("chunk%d", i), chunk)528 mustGet(t, s, fmt.Sprintf("chunk%d", i), chunk)
@@ -532,48 +549,49 @@ func TestInvalidInputs(t *testing.T) {
532 t.Fatalf("version 0: %v", err)549 t.Fatalf("version 0: %v", err)
533 }550 }
534 551
535- // Payload = 12 (seq, count) + 1 (kind) + 1 (klen) + 1 (key) + 1 (vlen) + v.552+ // Encoded batch = 4 (count) + 1 (kind) + 1 (klen) + 1 (key) + 1 (vlen) + v.
536 b.Reset()553 b.Reset()
537- b.Put([]byte("k"), make([]byte, 85))554+ b.Put([]byte("k"), make([]byte, 93))
538 if seq, err := db.Apply(bg, &b); !errors.Is(err, ErrBatchTooLarge) || seq != 0 {555 if seq, err := db.Apply(bg, &b); !errors.Is(err, ErrBatchTooLarge) || seq != 0 {
539- t.Fatalf("101-byte payload: %d, %v", seq, err)556+ t.Fatalf("101-byte batch: %d, %v", seq, err)
540 }557 }
541 b.Reset()558 b.Reset()
542- b.Put([]byte("k"), make([]byte, 84))559+ b.Put([]byte("k"), make([]byte, 92))
543 if _, err := db.Apply(bg, &b); err != nil {560 if _, err := db.Apply(bg, &b); err != nil {
544- t.Fatalf("100-byte payload: %v", err)561+ t.Fatalf("100-byte batch: %v", err)
545 }562 }
546- if db.Snapshot().Seq() != 1 {563+ if db.Snapshot(bg).Seq() != 1 {
547- t.Fatalf("Seq = %d: rejected batches reached the log", db.Snapshot().Seq())564+ t.Fatalf("Seq = %d: rejected batches reached the log", db.Snapshot(bg).Seq())
548 }565 }
549 }566 }
550 567
551 func TestOptionValidation(t *testing.T) {568 func TestOptionValidation(t *testing.T) {
552 bad := []Options{569 bad := []Options{
553 {MaxBatchBytes: maxRecord + 1},570 {MaxBatchBytes: maxRecord + 1},
571+ {MaxBatchBytes: maxRecord - 11},
554 {MaxBatchBytes: -1},572 {MaxBatchBytes: -1},
555 {GroupWindow: -time.Second},573 {GroupWindow: -time.Second},
556 }574 }
557 for _, o := range bad {575 for _, o := range bad {
558 fx := faultfs.New()576 fx := faultfs.New()
559- if db, err := open(fx, testDir, o); err == nil {577+ if db, err := open(testCtx(t), fx, testDir, o); err == nil {
560- _ = db.Close()578+ _ = db.Close(bg)
561 t.Errorf("open(%+v) succeeded", o)579 t.Errorf("open(%+v) succeeded", o)
562 }580 }
563 if _, err := fx.ReadDir(testDir); err == nil {581 if _, err := fx.ReadDir(testDir); err == nil {
564 t.Errorf("open(%+v) created the directory", o)582 t.Errorf("open(%+v) created the directory", o)
565 }583 }
566 }584 }
567- if _, err := Open(t.TempDir(), Options{MaxBatchBytes: 65 << 20}); err == nil {585+ if _, err := Open(testCtx(t), t.TempDir(), Options{MaxBatchBytes: 65 << 20}); err == nil {
568 t.Error("Open with MaxBatchBytes > 64 MiB succeeded")586 t.Error("Open with MaxBatchBytes > 64 MiB succeeded")
569 }587 }
570- for _, o := range []Options{{MaxBatchBytes: maxRecord}, {CompactAfter: -1}, {NoSync: true, CompactAfter: 1}} {588+ for _, o := range []Options{{MaxBatchBytes: maxRecord - 12}, {CompactAfter: -1}, {NoSync: true, CompactAfter: 1}} {
571- db, err := open(faultfs.New(), testDir, o)589+ db, err := open(testCtx(t), faultfs.New(), testDir, o)
572 if err != nil {590 if err != nil {
573 t.Errorf("open(%+v): %v", o, err)591 t.Errorf("open(%+v): %v", o, err)
574 continue592 continue
575 }593 }
576- _ = db.Close()594+ _ = db.Close(bg)
577 }595 }
578 }596 }
579 597
@@ -581,39 +599,39 @@ func TestLocked(t *testing.T) {
581 t.Run("faultfs", func(t *testing.T) {599 t.Run("faultfs", func(t *testing.T) {
582 fx := faultfs.New()600 fx := faultfs.New()
583 db := mustOpen(t, fx, Options{})601 db := mustOpen(t, fx, Options{})
584- if _, err := open(fx, testDir, Options{}); !errors.Is(err, ErrLocked) {602+ if _, err := open(testCtx(t), fx, testDir, Options{}); !errors.Is(err, ErrLocked) {
585 t.Fatalf("second open: %v, want ErrLocked", err)603 t.Fatalf("second open: %v, want ErrLocked", err)
586 }604 }
587- if err := db.Close(); err != nil {605+ if err := db.Close(bg); err != nil {
588 t.Fatal(err)606 t.Fatal(err)
589 }607 }
590 mustOpen(t, fx, Options{})608 mustOpen(t, fx, Options{})
591 })609 })
592 t.Run("os", func(t *testing.T) {610 t.Run("os", func(t *testing.T) {
593 dir := t.TempDir()611 dir := t.TempDir()
594- db, err := Open(dir, Options{})612+ db, err := Open(testCtx(t), dir, Options{})
595 if err != nil {613 if err != nil {
596 t.Fatal(err)614 t.Fatal(err)
597 }615 }
598- if _, err := Open(dir, Options{}); !errors.Is(err, ErrLocked) {616+ if _, err := Open(testCtx(t), dir, Options{}); !errors.Is(err, ErrLocked) {
599 t.Fatalf("second Open: %v, want ErrLocked", err)617 t.Fatalf("second Open: %v, want ErrLocked", err)
600 }618 }
601 putKV(t, db, "still", "works")619 putKV(t, db, "still", "works")
602- if err := db.Close(); err != nil {620+ if err := db.Close(bg); err != nil {
603 t.Fatal(err)621 t.Fatal(err)
604 }622 }
605- db, err = Open(dir, Options{})623+ db, err = Open(testCtx(t), dir, Options{})
606 if err != nil {624 if err != nil {
607 t.Fatalf("Open after Close: %v", err)625 t.Fatalf("Open after Close: %v", err)
608 }626 }
609- db.Close()627+ _ = db.Close(bg)
610 })628 })
611 }629 }
612 630
613 func TestApplyAfterClose(t *testing.T) {631 func TestApplyAfterClose(t *testing.T) {
614 db := mustOpen(t, faultfs.New(), Options{})632 db := mustOpen(t, faultfs.New(), Options{})
615 putKV(t, db, "a", "1")633 putKV(t, db, "a", "1")
616- if err := db.Close(); err != nil {634+ if err := db.Close(bg); err != nil {
617 t.Fatal(err)635 t.Fatal(err)
618 }636 }
619 var b Batch637 var b Batch
@@ -621,8 +639,8 @@ func TestApplyAfterClose(t *testing.T) {
621 if _, err := db.Apply(bg, &b); !errors.Is(err, ErrClosed) {639 if _, err := db.Apply(bg, &b); !errors.Is(err, ErrClosed) {
622 t.Fatalf("Apply after Close: %v", err)640 t.Fatalf("Apply after Close: %v", err)
623 }641 }
624- mustGet(t, db.Snapshot(), "a", "1") // reads keep working642+ mustGet(t, db.Snapshot(bg), "a", "1") // reads keep working
625- if err := db.Close(); err != nil {643+ if err := db.Close(bg); err != nil {
626 t.Fatalf("second Close: %v", err)644 t.Fatalf("second Close: %v", err)
627 }645 }
628 }646 }
@@ -671,10 +689,10 @@ func TestContextBeforeDequeue(t *testing.T) {
671 t.Fatalf("Apply with cancelled ctx = %v", err)689 t.Fatalf("Apply with cancelled ctx = %v", err)
672 }690 }
673 putKV(t, db, "fourth", "4") // the writer is still healthy691 putKV(t, db, "fourth", "4") // the writer is still healthy
674- if err := db.Close(); err != nil {692+ if err := db.Close(bg); err != nil {
675 t.Fatal(err)693 t.Fatal(err)
676 }694 }
677- s := mustOpen(t, fx, Options{}).Snapshot()695+ s := mustOpen(t, fx, Options{}).Snapshot(bg)
678 mustGet(t, s, "first", "1")696 mustGet(t, s, "first", "1")
679 mustGet(t, s, "fourth", "4")697 mustGet(t, s, "fourth", "4")
680 mustMissing(t, s, "second")698 mustMissing(t, s, "second")
@@ -712,7 +730,7 @@ func TestCloseDuringApplies(t *testing.T) {
712 }730 }
713 time.Sleep(time.Duration(2+i) * time.Millisecond)731 time.Sleep(time.Duration(2+i) * time.Millisecond)
714 closed := make(chan error, 1)732 closed := make(chan error, 1)
715- go func() { closed <- db.Close() }()733+ go func() { closed <- db.Close(bg) }()
716 select {734 select {
717 case err := <-closed:735 case err := <-closed:
718 if err != nil {736 if err != nil {
@@ -724,12 +742,12 @@ func TestCloseDuringApplies(t *testing.T) {
724 wg.Wait()742 wg.Wait()
725 743
726 fx.SetSyncDelay(0)744 fx.SetSyncDelay(0)
727- s := mustOpen(t, fx, Options{}).Snapshot()745+ s := mustOpen(t, fx, Options{}).Snapshot(bg)
728 for g, n := range acked {746 for g, n := range acked {
729 if n == 0 {747 if n == 0 {
730 continue748 continue
731 }749 }
732- it, ok := s.Get(fmt.Appendf(nil, "g%02d", g))750+ it, ok := s.Get(bg, fmt.Appendf(nil, "g%02d", g))
733 got, _ := strconv.Atoi(string(it.Value))751 got, _ := strconv.Atoi(string(it.Value))
734 // The batch after the last acknowledged one may have been752 // The batch after the last acknowledged one may have been
735 // committed by the final group.753 // committed by the final group.
@@ -767,8 +785,9 @@ func TestInjectedFailures(t *testing.T) {
767 785
768 func testInjectedFailure(t *testing.T, f failure, powerLoss bool) {786 func testInjectedFailure(t *testing.T, f failure, powerLoss bool) {
769 fx := faultfs.New()787 fx := faultfs.New()
770- logger, logs := captureLogger()788+ var logs syncBuffer
771- db := mustOpen(t, fx, Options{GroupWindow: 150 * time.Millisecond, Logger: logger})789+ ctx := zerowrap.WithCtx(bg, zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
790+ db := mustOpenCtx(t, ctx, fx, Options{GroupWindow: 150 * time.Millisecond})
772 putKV(t, db, "before", "1")791 putKV(t, db, "before", "1")
773 f.inject(fx)792 f.inject(fx)
774 793
@@ -792,7 +811,7 @@ func testInjectedFailure(t *testing.T, f failure, powerLoss bool) {
792 t.Fatalf("batch %d: %v does not wrap %v", i, err, f.cause)811 t.Fatalf("batch %d: %v does not wrap %v", i, err, f.cause)
793 }812 }
794 }813 }
795- if !strings.Contains(logs.String(), "database failed") {814+ if !strings.Contains(logs.String(), `"level":"error"`) || !strings.Contains(logs.String(), "database failed") {
796 t.Errorf("failure not logged: %q", logs.String())815 t.Errorf("failure not logged: %q", logs.String())
797 }816 }
798 817
@@ -808,27 +827,27 @@ func testInjectedFailure(t *testing.T, f failure, powerLoss bool) {
808 if _, err := db.Apply(bg, &empty); !errors.Is(err, ErrFailed) {827 if _, err := db.Apply(bg, &empty); !errors.Is(err, ErrFailed) {
809 t.Fatalf("empty Apply on a failed db: %v", err)828 t.Fatalf("empty Apply on a failed db: %v", err)
810 }829 }
811- s := db.Snapshot()830+ s := db.Snapshot(bg)
812 if s.Seq() != 1 || s.Len() != 1 {831 if s.Seq() != 1 || s.Len() != 1 {
813 t.Fatalf("snapshot after failure: seq %d len %d", s.Seq(), s.Len())832 t.Fatalf("snapshot after failure: seq %d len %d", s.Seq(), s.Len())
814 }833 }
815 mustGet(t, s, "before", "1")834 mustGet(t, s, "before", "1")
816 mustMissing(t, s, "failed0")835 mustMissing(t, s, "failed0")
817- if err := db.Close(); !errors.Is(err, ErrFailed) {836+ if err := db.Close(bg); !errors.Is(err, ErrFailed) {
818 t.Fatalf("Close = %v, want the failure", err)837 t.Fatalf("Close = %v, want the failure", err)
819 }838 }
820- if err := db.Close(); err != nil {839+ if err := db.Close(bg); err != nil {
821 t.Fatalf("second Close = %v", err)840 t.Fatalf("second Close = %v", err)
822 }841 }
823 842
824 if powerLoss {843 if powerLoss {
825 fx.PowerLoss(faultfs.PowerLossOptions{})844 fx.PowerLoss(faultfs.PowerLossOptions{})
826 }845 }
827- s = mustOpen(t, fx, Options{}).Snapshot()846+ s = mustOpen(t, fx, Options{}).Snapshot(bg)
828 mustGet(t, s, "before", "1")847 mustGet(t, s, "before", "1")
829 present := 0848 present := 0
830 for i := range group {849 for i := range group {
831- if _, ok := s.Get(fmt.Appendf(nil, "failed%d", i)); ok {850+ if _, ok := s.Get(bg, fmt.Appendf(nil, "failed%d", i)); ok {
832 present++851 present++
833 }852 }
834 }853 }
@@ -868,12 +887,6 @@ func TestFailedWriterAnswersOverflow(t *testing.T) {
868 }887 }
869 }888 }
870 889
871-// captureLogger returns a logger writing to a buffer.
872-func captureLogger() (*slog.Logger, *syncBuffer) {
873- var sb syncBuffer
874- return slog.New(slog.NewTextHandler(&sb, nil)), &sb
875-}
876-
877 type syncBuffer struct {890 type syncBuffer struct {
878 mu sync.Mutex891 mu sync.Mutex
879 b strings.Builder892 b strings.Builder
@@ -910,8 +923,10 @@ func powerLossBase(t *testing.T) uint64 {
910 // Every writer g applies batches n = 0, 1, … that set cur/g = n and923 // Every writer g applies batches n = 0, 1, … that set cur/g = n and
911 // all/g/n, and delete all/g/(n-1). The recovered state therefore says how924 // all/g/n, and delete all/g/(n-1). The recovered state therefore says how
912 // many batches of each writer were applied, and the seq must equal their sum.925 // many batches of each writer were applied, and the seq must equal their sum.
913-// (PowerLossOptions.KeepPages is not exercised: see the open question about926+// The modes include KeepPages (a random subset of the unsynced pages
914-// recovery rule 6 under out-of-order page writeback.)927+// survives). They always run with NoSync=false: with NoSync, KeepPages can
928+// legitimately make Open fail with ErrCorrupt, which is documented on
929+// Options.NoSync and not tested.
915 func TestPowerLoss(t *testing.T) {930 func TestPowerLoss(t *testing.T) {
916 modes := []struct {931 modes := []struct {
917 name string932 name string
@@ -919,6 +934,10 @@ func TestPowerLoss(t *testing.T) {
919 }{934 }{
920 {"drop", faultfs.PowerLossOptions{}},935 {"drop", faultfs.PowerLossOptions{}},
921 {"zerofill", faultfs.PowerLossOptions{ZeroFill: true}},936 {"zerofill", faultfs.PowerLossOptions{ZeroFill: true}},
937+ // Pages of the unsynced tail reach the disk in any order. One record
938+ // per group makes this safe when commits are synced.
939+ {"keeppages", faultfs.PowerLossOptions{KeepPages: true}},
940+ {"keeppages+zerofill", faultfs.PowerLossOptions{KeepPages: true, ZeroFill: true}},
922 }941 }
923 base := powerLossBase(t)942 base := powerLossBase(t)
924 iterations := short(150, 10)943 iterations := short(150, 10)
@@ -937,7 +956,7 @@ func TestPowerLoss(t *testing.T) {
937 func runPowerLoss(t *testing.T, seed uint64, plo faultfs.PowerLossOptions) (ok bool) {956 func runPowerLoss(t *testing.T, seed uint64, plo faultfs.PowerLossOptions) (ok bool) {
938 rng := rand.New(rand.NewPCG(seed, 0x9e3779b97f4a7c15))957 rng := rand.New(rand.NewPCG(seed, 0x9e3779b97f4a7c15))
939 fx := faultfs.New()958 fx := faultfs.New()
940- db, err := open(fx, testDir, Options{GroupWindow: time.Duration(rng.IntN(3)) * 100 * time.Microsecond})959+ db, err := open(testCtx(t), fx, testDir, Options{GroupWindow: time.Duration(rng.IntN(3)) * 100 * time.Microsecond})
941 if err != nil {960 if err != nil {
942 t.Errorf("seed %d: open: %v", seed, err)961 t.Errorf("seed %d: open: %v", seed, err)
943 return false962 return false
@@ -966,39 +985,43 @@ func runPowerLoss(t *testing.T, seed uint64, plo faultfs.PowerLossOptions) (ok b
966 })985 })
967 }986 }
968 time.Sleep(time.Duration(rng.IntN(4000)) * time.Microsecond)987 time.Sleep(time.Duration(rng.IntN(4000)) * time.Microsecond)
988+ plo.Rand = rand.New(rand.NewPCG(seed, 7))
989+ if plo.KeepPages {
990+ plo.PageSize = 64 // small pages: a group record spans several of them
991+ }
969 fx.PowerLoss(plo)992 fx.PowerLoss(plo)
970 wg.Wait()993 wg.Wait()
971- _ = db.Close()994+ _ = db.Close(bg)
972 fx.SetSyncDelay(0)995 fx.SetSyncDelay(0)
973 996
974- db, err = open(fx, testDir, Options{})997+ db, err = open(testCtx(t), fx, testDir, Options{})
975 if err != nil {998 if err != nil {
976 t.Errorf("seed %d: reopen after power loss: %v", seed, err)999 t.Errorf("seed %d: reopen after power loss: %v", seed, err)
977 return false1000 return false
978 }1001 }
979- defer db.Close()1002+ defer func() { _ = db.Close(bg) }()
980- if !checkPowerLossState(t, seed, db.Snapshot(), acked, ackSeq) {1003+ if !checkPowerLossState(t, seed, db.Snapshot(bg), acked, ackSeq) {
981 return false1004 return false
982 }1005 }
983 1006
984 // The recovered log accepts new records and survives another reopen.1007 // The recovered log accepts new records and survives another reopen.
985- seq := db.Snapshot().Seq()1008+ seq := db.Snapshot(bg).Seq()
986 if got := putKV(t, db, "after", "x"); got != seq+1 {1009 if got := putKV(t, db, "after", "x"); got != seq+1 {
987 t.Errorf("seed %d: seq after recovery = %d, want %d", seed, got, seq+1)1010 t.Errorf("seed %d: seq after recovery = %d, want %d", seed, got, seq+1)
988 return false1011 return false
989 }1012 }
990- if err := db.Close(); err != nil {1013+ if err := db.Close(bg); err != nil {
991 t.Errorf("seed %d: Close: %v", seed, err)1014 t.Errorf("seed %d: Close: %v", seed, err)
992 return false1015 return false
993 }1016 }
994- db, err = open(fx, testDir, Options{})1017+ db, err = open(testCtx(t), fx, testDir, Options{})
995 if err != nil {1018 if err != nil {
996 t.Errorf("seed %d: second reopen: %v", seed, err)1019 t.Errorf("seed %d: second reopen: %v", seed, err)
997 return false1020 return false
998 }1021 }
999- defer db.Close()1022+ defer func() { _ = db.Close(bg) }()
1000- if db.Snapshot().Seq() != seq+1 {1023+ if db.Snapshot(bg).Seq() != seq+1 {
1001- t.Errorf("seed %d: seq after second reopen = %d, want %d", seed, db.Snapshot().Seq(), seq+1)1024+ t.Errorf("seed %d: seq after second reopen = %d, want %d", seed, db.Snapshot(bg).Seq(), seq+1)
1002 return false1025 return false
1003 }1026 }
1004 return true1027 return true
@@ -1012,7 +1035,7 @@ func checkPowerLossState(t *testing.T, seed uint64, s *Snapshot, acked []int, ac
1012 var applied uint641035 var applied uint64
1013 keys := 01036 keys := 0
1014 for g := range acked {1037 for g := range acked {
1015- it, ok := s.Get(fmt.Appendf(nil, "cur/%d", g))1038+ it, ok := s.Get(bg, fmt.Appendf(nil, "cur/%d", g))
1016 last := -11039 last := -1
1017 if ok {1040 if ok {
1018 n, err := strconv.Atoi(string(it.Value))1041 n, err := strconv.Atoi(string(it.Value))
@@ -1030,12 +1053,12 @@ func checkPowerLossState(t *testing.T, seed uint64, s *Snapshot, acked []int, ac
1030 if !ok {1053 if !ok {
1031 continue1054 continue
1032 }1055 }
1033- cur, ok := s.Get(fmt.Appendf(nil, "all/%d/%d", g, last))1056+ cur, ok := s.Get(bg, fmt.Appendf(nil, "all/%d/%d", g, last))
1034 if !ok || cur.Version != it.Version {1057 if !ok || cur.Version != it.Version {
1035 return fail("writer %d: batch %d is partially applied (%v, %v)", g, last, cur, ok)1058 return fail("writer %d: batch %d is partially applied (%v, %v)", g, last, cur, ok)
1036 }1059 }
1037 count := 01060 count := 0
1038- for range s.Prefix(fmt.Appendf(nil, "all/%d/", g)) {1061+ for range s.Prefix(bg, fmt.Appendf(nil, "all/%d/", g)) {
1039 count++1062 count++
1040 }1063 }
1041 if count != 1 {1064 if count != 1 {
@@ -1082,11 +1105,11 @@ func (f countFile) Sync() error {
1082 // around the OS file system, so the numbers are real fdatasync calls.1105 // around the OS file system, so the numbers are real fdatasync calls.
1083 func BenchmarkApply(b *testing.B) {1106 func BenchmarkApply(b *testing.B) {
1084 fx := &countFS{FS: fsys.OS()}1107 fx := &countFS{FS: fsys.OS()}
1085- db, err := open(fx, filepath.Join(b.TempDir(), "db"), Options{})1108+ db, err := open(bg, fx, filepath.Join(b.TempDir(), "db"), Options{})
1086 if err != nil {1109 if err != nil {
1087 b.Fatal(err)1110 b.Fatal(err)
1088 }1111 }
1089- defer db.Close()1112+ defer func() { _ = db.Close(bg) }()
1090 b.SetParallelism(16)1113 b.SetParallelism(16)
1091 value := bytes.Repeat([]byte("v"), 100)1114 value := bytes.Repeat([]byte("v"), 100)
1092 var next atomic.Uint641115 var next atomic.Uint64
M go.mod +10 -0
@@ -1,3 +1,13 @@
11 module bnema.dev/ermite-kv
22
33 go 1.27.1
4+
5+require github.com/bnema/zerowrap v1.4.1
6+
7+require (
8+ github.com/mattn/go-colorable v0.1.15 // indirect
9+ github.com/mattn/go-isatty v0.0.22 // indirect
10+ github.com/rs/zerolog v1.35.1 // indirect
11+ golang.org/x/sys v0.45.0 // indirect
12+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
13+)
@@ -1,3 +1,13 @@
1 module bnema.dev/ermite-kv1 module bnema.dev/ermite-kv
2 2
3 go 1.27.13 go 1.27.1
4+
5+require github.com/bnema/zerowrap v1.4.1
6+
7+require (
8+ github.com/mattn/go-colorable v0.1.15 // indirect
9+ github.com/mattn/go-isatty v0.0.22 // indirect
10+ github.com/rs/zerolog v1.35.1 // indirect
11+ golang.org/x/sys v0.45.0 // indirect
12+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
13+)
A go.sum +12 -0
@@ -0,0 +1,12 @@
1+github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
2+github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
3+github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
4+github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
5+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
6+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
7+github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
8+github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
9+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
10+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
11+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
12+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
@@ -0,0 +1,12 @@
1+github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU=
2+github.com/bnema/zerowrap v1.4.1/go.mod h1:8M8Qz3uRpbszYkXqi0Dnr1TZ4lDrQY8/orGm30zwKKg=
3+github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
4+github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
5+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
6+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
7+github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
8+github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
9+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
10+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
11+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
12+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
M record.go +80 -40
@@ -29,7 +29,9 @@ const (
2929 snapshotMagic = "EKVSNP01"
3030 logHeaderSize = 8 + 8 + 4
3131 recordFrame = 4 + 4 // len + crc
32- recordMinBody = 8 + 4 // seq + count
32+ recordHead = 8 + 4 // seq + batch count
33+ batchHeader = 4 // op count
34+ recordMinBody = 16 // smallest readable payload length
3335 snapHeaderSize = 8 + 8 + 8 + 4
3436 minOpSize = 3 // kind + klen + 1 key byte
3537 )
@@ -85,13 +87,20 @@ func decodeLogHeader(b []byte) (base uint64, err error) {
8587 return binary.LittleEndian.Uint64(b[8:16]), nil
8688 }
8789
88-// appendRecord appends one encoded record (len, crc, seq, count, ops) to dst.
89-// The payload must not exceed maxRecord and ops must be non-empty with valid
90-// keys; the caller enforces this (batches are validated before encoding).
91-func appendRecord(dst []byte, seq uint64, ops []op) []byte {
92- start := len(dst)
90+// beginRecord starts a group record: it appends the frame placeholder, the
91+// seq of the first batch and the batch count placeholder to dst. Batches are
92+// added with appendBatch and the record is completed by finishRecord, which
93+// expects the record to start at index 0 of the slice.
94+func beginRecord(dst []byte, seq uint64) []byte {
9395 dst = append(dst, make([]byte, recordFrame)...)
9496 dst = binary.LittleEndian.AppendUint64(dst, seq)
97+ return binary.LittleEndian.AppendUint32(dst, 0)
98+}
99+
100+// appendBatch appends one batch (count, ops) to the record being built. ops
101+// must be non-empty with valid keys; the caller enforces this (batches are
102+// validated before encoding).
103+func appendBatch(dst []byte, ops []op) []byte {
95104 dst = binary.LittleEndian.AppendUint32(dst, uint32(len(ops)))
96105 for _, o := range ops {
97106 dst = append(dst, o.kind)
@@ -102,15 +111,33 @@ func appendRecord(dst []byte, seq uint64, ops []op) []byte {
102111 dst = append(dst, o.value...)
103112 }
104113 }
105- payload := dst[start+recordFrame:]
106- binary.LittleEndian.PutUint32(dst[start:], uint32(len(payload)))
107- binary.LittleEndian.PutUint32(dst[start+4:], crc32.Checksum(payload, castagnoli))
108114 return dst
109115 }
110116
117+// finishRecord patches the batch count, length and checksum into rec, which
118+// holds exactly one record started by beginRecord.
119+func finishRecord(rec []byte, batches int) {
120+ binary.LittleEndian.PutUint32(rec[recordFrame+8:], uint32(batches))
121+ payload := rec[recordFrame:]
122+ binary.LittleEndian.PutUint32(rec, uint32(len(payload)))
123+ binary.LittleEndian.PutUint32(rec[4:], crc32.Checksum(payload, castagnoli))
124+}
125+
126+// appendRecord appends one encoded group record to dst. Batch i has seq
127+// seq+i. The payload must not exceed maxRecord and every batch must hold
128+// valid ops.
129+func appendRecord(dst []byte, seq uint64, batches ...[]op) []byte {
130+ rec := beginRecord(nil, seq)
131+ for _, ops := range batches {
132+ rec = appendBatch(rec, ops)
133+ }
134+ finishRecord(rec, len(batches))
135+ return append(dst, rec...)
136+}
137+
111138 // peekRecord returns the total encoded size (frame + payload) announced by
112139 // the record frame at the start of b. ok is false if fewer than 8 bytes are
113-// present or the announced payload length is outside [12, maxRecord]. It does
140+// present or the announced payload length is outside [16, maxRecord]. It does
114141 // not check that the payload is present.
115142 func peekRecord(b []byte) (total int, ok bool) {
116143 if len(b) < recordFrame {
@@ -124,13 +151,14 @@ func peekRecord(b []byte) (total int, ok bool) {
124151 }
125152
126153 // decodeRecord decodes the record at the start of b. A record is readable
127-// only if the frame is present, 12 ≤ len ≤ maxRecord, len payload bytes are
128-// present, the CRC matches, and the payload holds exactly count ≥ 1 valid
129-// ops consuming exactly len bytes. Otherwise the error wraps errUnreadable.
154+// only if the frame is present, 16 ≤ len ≤ maxRecord, len payload bytes are
155+// present, the CRC matches, and the payload holds batches ≥ 1 batches of
156+// count ≥ 1 valid ops each, consuming exactly len bytes. Otherwise the error
157+// wraps errUnreadable.
130158 //
131-// The decoded ops are appended to ops[:0]; their keys and values alias b.
132-// n is the number of bytes consumed.
133-func decodeRecord(b []byte, ops []op) (seq uint64, out []op, n int, err error) {
159+// Batch i has seq seq+i. The decoded keys and values alias b. n is the number
160+// of bytes consumed.
161+func decodeRecord(b []byte) (seq uint64, batches [][]op, n int, err error) {
134162 total, ok := peekRecord(b)
135163 if !ok {
136164 return 0, nil, 0, fmt.Errorf("%w: bad or short frame", errUnreadable)
@@ -142,45 +170,57 @@ func decodeRecord(b []byte, ops []op) (seq uint64, out []op, n int, err error) {
142170 if crc32.Checksum(payload, castagnoli) != binary.LittleEndian.Uint32(b[4:8]) {
143171 return 0, nil, 0, fmt.Errorf("%w: checksum mismatch", errUnreadable)
144172 }
145- seq, out, err = decodePayload(payload, ops)
173+ seq, batches, err = decodePayload(payload)
146174 if err != nil {
147175 return 0, nil, 0, err
148176 }
149- return seq, out, total, nil
177+ return seq, batches, total, nil
150178 }
151179
152-// decodePayload decodes seq, count and ops from a whole payload.
153-func decodePayload(p []byte, ops []op) (uint64, []op, error) {
180+// decodePayload decodes seq, batch count and batches from a whole payload.
181+func decodePayload(p []byte) (uint64, [][]op, error) {
154182 seq := binary.LittleEndian.Uint64(p)
155- count := binary.LittleEndian.Uint32(p[8:])
156- p = p[recordMinBody:]
157- if count == 0 || uint64(count) > uint64(len(p)/minOpSize) {
158- return 0, nil, fmt.Errorf("%w: bad op count %d", errUnreadable, count)
183+ nb := binary.LittleEndian.Uint32(p[8:])
184+ p = p[recordHead:]
185+ if nb == 0 || uint64(nb) > uint64(len(p)/(batchHeader+minOpSize)) {
186+ return 0, nil, fmt.Errorf("%w: bad batch count %d", errUnreadable, nb)
159187 }
160- ops = ops[:0]
161- for range count {
162- if len(p) == 0 {
163- return 0, nil, fmt.Errorf("%w: truncated op", errUnreadable)
164- }
165- o := op{kind: p[0]}
166- if o.kind != opPut && o.kind != opDelete {
167- return 0, nil, fmt.Errorf("%w: bad op kind %d", errUnreadable, o.kind)
188+ batches := make([][]op, 0, nb)
189+ for i := range nb {
190+ if len(p) < batchHeader {
191+ return 0, nil, fmt.Errorf("%w: truncated batch %d", errUnreadable, i)
168192 }
169- var ok bool
170- if o.key, p, ok = cutBytes(p[1:], maxKeyLen); !ok || len(o.key) == 0 {
171- return 0, nil, fmt.Errorf("%w: bad key", errUnreadable)
193+ count := binary.LittleEndian.Uint32(p)
194+ p = p[batchHeader:]
195+ if count == 0 || uint64(count) > uint64(len(p)/minOpSize) {
196+ return 0, nil, fmt.Errorf("%w: bad op count %d in batch %d", errUnreadable, count, i)
172197 }
173- if o.kind == opPut {
174- if o.value, p, ok = cutBytes(p, maxRecord); !ok {
175- return 0, nil, fmt.Errorf("%w: bad value", errUnreadable)
198+ ops := make([]op, 0, count)
199+ for range count {
200+ if len(p) == 0 {
201+ return 0, nil, fmt.Errorf("%w: truncated op", errUnreadable)
202+ }
203+ o := op{kind: p[0]}
204+ if o.kind != opPut && o.kind != opDelete {
205+ return 0, nil, fmt.Errorf("%w: bad op kind %d", errUnreadable, o.kind)
206+ }
207+ var ok bool
208+ if o.key, p, ok = cutBytes(p[1:], maxKeyLen); !ok || len(o.key) == 0 {
209+ return 0, nil, fmt.Errorf("%w: bad key", errUnreadable)
210+ }
211+ if o.kind == opPut {
212+ if o.value, p, ok = cutBytes(p, maxRecord); !ok {
213+ return 0, nil, fmt.Errorf("%w: bad value", errUnreadable)
214+ }
176215 }
216+ ops = append(ops, o)
177217 }
178- ops = append(ops, o)
218+ batches = append(batches, ops)
179219 }
180220 if len(p) != 0 {
181221 return 0, nil, fmt.Errorf("%w: %d trailing payload bytes", errUnreadable, len(p))
182222 }
183- return seq, ops, nil
223+ return seq, batches, nil
184224 }
185225
186226 // cutBytes reads a uvarint length (at most limit) and that many bytes from
@@ -29,7 +29,9 @@ const (
29 snapshotMagic = "EKVSNP01"29 snapshotMagic = "EKVSNP01"
30 logHeaderSize = 8 + 8 + 430 logHeaderSize = 8 + 8 + 4
31 recordFrame = 4 + 4 // len + crc31 recordFrame = 4 + 4 // len + crc
32- recordMinBody = 8 + 4 // seq + count32+ recordHead = 8 + 4 // seq + batch count
33+ batchHeader = 4 // op count
34+ recordMinBody = 16 // smallest readable payload length
33 snapHeaderSize = 8 + 8 + 8 + 435 snapHeaderSize = 8 + 8 + 8 + 4
34 minOpSize = 3 // kind + klen + 1 key byte36 minOpSize = 3 // kind + klen + 1 key byte
35 )37 )
@@ -85,13 +87,20 @@ func decodeLogHeader(b []byte) (base uint64, err error) {
85 return binary.LittleEndian.Uint64(b[8:16]), nil87 return binary.LittleEndian.Uint64(b[8:16]), nil
86 }88 }
87 89
88-// appendRecord appends one encoded record (len, crc, seq, count, ops) to dst.90+// beginRecord starts a group record: it appends the frame placeholder, the
89-// The payload must not exceed maxRecord and ops must be non-empty with valid91+// seq of the first batch and the batch count placeholder to dst. Batches are
90-// keys; the caller enforces this (batches are validated before encoding).92+// added with appendBatch and the record is completed by finishRecord, which
91-func appendRecord(dst []byte, seq uint64, ops []op) []byte {93+// expects the record to start at index 0 of the slice.
92- start := len(dst)94+func beginRecord(dst []byte, seq uint64) []byte {
93 dst = append(dst, make([]byte, recordFrame)...)95 dst = append(dst, make([]byte, recordFrame)...)
94 dst = binary.LittleEndian.AppendUint64(dst, seq)96 dst = binary.LittleEndian.AppendUint64(dst, seq)
97+ return binary.LittleEndian.AppendUint32(dst, 0)
98+}
99+
100+// appendBatch appends one batch (count, ops) to the record being built. ops
101+// must be non-empty with valid keys; the caller enforces this (batches are
102+// validated before encoding).
103+func appendBatch(dst []byte, ops []op) []byte {
95 dst = binary.LittleEndian.AppendUint32(dst, uint32(len(ops)))104 dst = binary.LittleEndian.AppendUint32(dst, uint32(len(ops)))
96 for _, o := range ops {105 for _, o := range ops {
97 dst = append(dst, o.kind)106 dst = append(dst, o.kind)
@@ -102,15 +111,33 @@ func appendRecord(dst []byte, seq uint64, ops []op) []byte {
102 dst = append(dst, o.value...)111 dst = append(dst, o.value...)
103 }112 }
104 }113 }
105- payload := dst[start+recordFrame:]
106- binary.LittleEndian.PutUint32(dst[start:], uint32(len(payload)))
107- binary.LittleEndian.PutUint32(dst[start+4:], crc32.Checksum(payload, castagnoli))
108 return dst114 return dst
109 }115 }
110 116
117+// finishRecord patches the batch count, length and checksum into rec, which
118+// holds exactly one record started by beginRecord.
119+func finishRecord(rec []byte, batches int) {
120+ binary.LittleEndian.PutUint32(rec[recordFrame+8:], uint32(batches))
121+ payload := rec[recordFrame:]
122+ binary.LittleEndian.PutUint32(rec, uint32(len(payload)))
123+ binary.LittleEndian.PutUint32(rec[4:], crc32.Checksum(payload, castagnoli))
124+}
125+
126+// appendRecord appends one encoded group record to dst. Batch i has seq
127+// seq+i. The payload must not exceed maxRecord and every batch must hold
128+// valid ops.
129+func appendRecord(dst []byte, seq uint64, batches ...[]op) []byte {
130+ rec := beginRecord(nil, seq)
131+ for _, ops := range batches {
132+ rec = appendBatch(rec, ops)
133+ }
134+ finishRecord(rec, len(batches))
135+ return append(dst, rec...)
136+}
137+
111 // peekRecord returns the total encoded size (frame + payload) announced by138 // peekRecord returns the total encoded size (frame + payload) announced by
112 // the record frame at the start of b. ok is false if fewer than 8 bytes are139 // the record frame at the start of b. ok is false if fewer than 8 bytes are
113-// present or the announced payload length is outside [12, maxRecord]. It does140+// present or the announced payload length is outside [16, maxRecord]. It does
114 // not check that the payload is present.141 // not check that the payload is present.
115 func peekRecord(b []byte) (total int, ok bool) {142 func peekRecord(b []byte) (total int, ok bool) {
116 if len(b) < recordFrame {143 if len(b) < recordFrame {
@@ -124,13 +151,14 @@ func peekRecord(b []byte) (total int, ok bool) {
124 }151 }
125 152
126 // decodeRecord decodes the record at the start of b. A record is readable153 // decodeRecord decodes the record at the start of b. A record is readable
127-// only if the frame is present, 12 ≤ len ≤ maxRecord, len payload bytes are154+// only if the frame is present, 16 ≤ len ≤ maxRecord, len payload bytes are
128-// present, the CRC matches, and the payload holds exactly count ≥ 1 valid155+// present, the CRC matches, and the payload holds batches ≥ 1 batches of
129-// ops consuming exactly len bytes. Otherwise the error wraps errUnreadable.156+// count ≥ 1 valid ops each, consuming exactly len bytes. Otherwise the error
157+// wraps errUnreadable.
130 //158 //
131-// The decoded ops are appended to ops[:0]; their keys and values alias b.159+// Batch i has seq seq+i. The decoded keys and values alias b. n is the number
132-// n is the number of bytes consumed.160+// of bytes consumed.
133-func decodeRecord(b []byte, ops []op) (seq uint64, out []op, n int, err error) {161+func decodeRecord(b []byte) (seq uint64, batches [][]op, n int, err error) {
134 total, ok := peekRecord(b)162 total, ok := peekRecord(b)
135 if !ok {163 if !ok {
136 return 0, nil, 0, fmt.Errorf("%w: bad or short frame", errUnreadable)164 return 0, nil, 0, fmt.Errorf("%w: bad or short frame", errUnreadable)
@@ -142,45 +170,57 @@ func decodeRecord(b []byte, ops []op) (seq uint64, out []op, n int, err error) {
142 if crc32.Checksum(payload, castagnoli) != binary.LittleEndian.Uint32(b[4:8]) {170 if crc32.Checksum(payload, castagnoli) != binary.LittleEndian.Uint32(b[4:8]) {
143 return 0, nil, 0, fmt.Errorf("%w: checksum mismatch", errUnreadable)171 return 0, nil, 0, fmt.Errorf("%w: checksum mismatch", errUnreadable)
144 }172 }
145- seq, out, err = decodePayload(payload, ops)173+ seq, batches, err = decodePayload(payload)
146 if err != nil {174 if err != nil {
147 return 0, nil, 0, err175 return 0, nil, 0, err
148 }176 }
149- return seq, out, total, nil177+ return seq, batches, total, nil
150 }178 }
151 179
152-// decodePayload decodes seq, count and ops from a whole payload.180+// decodePayload decodes seq, batch count and batches from a whole payload.
153-func decodePayload(p []byte, ops []op) (uint64, []op, error) {181+func decodePayload(p []byte) (uint64, [][]op, error) {
154 seq := binary.LittleEndian.Uint64(p)182 seq := binary.LittleEndian.Uint64(p)
155- count := binary.LittleEndian.Uint32(p[8:])183+ nb := binary.LittleEndian.Uint32(p[8:])
156- p = p[recordMinBody:]184+ p = p[recordHead:]
157- if count == 0 || uint64(count) > uint64(len(p)/minOpSize) {185+ if nb == 0 || uint64(nb) > uint64(len(p)/(batchHeader+minOpSize)) {
158- return 0, nil, fmt.Errorf("%w: bad op count %d", errUnreadable, count)186+ return 0, nil, fmt.Errorf("%w: bad batch count %d", errUnreadable, nb)
159 }187 }
160- ops = ops[:0]188+ batches := make([][]op, 0, nb)
161- for range count {189+ for i := range nb {
162- if len(p) == 0 {190+ if len(p) < batchHeader {
163- return 0, nil, fmt.Errorf("%w: truncated op", errUnreadable)191+ return 0, nil, fmt.Errorf("%w: truncated batch %d", errUnreadable, i)
164- }
165- o := op{kind: p[0]}
166- if o.kind != opPut && o.kind != opDelete {
167- return 0, nil, fmt.Errorf("%w: bad op kind %d", errUnreadable, o.kind)
168 }192 }
169- var ok bool193+ count := binary.LittleEndian.Uint32(p)
170- if o.key, p, ok = cutBytes(p[1:], maxKeyLen); !ok || len(o.key) == 0 {194+ p = p[batchHeader:]
171- return 0, nil, fmt.Errorf("%w: bad key", errUnreadable)195+ if count == 0 || uint64(count) > uint64(len(p)/minOpSize) {
196+ return 0, nil, fmt.Errorf("%w: bad op count %d in batch %d", errUnreadable, count, i)
172 }197 }
173- if o.kind == opPut {198+ ops := make([]op, 0, count)
174- if o.value, p, ok = cutBytes(p, maxRecord); !ok {199+ for range count {
175- return 0, nil, fmt.Errorf("%w: bad value", errUnreadable)200+ if len(p) == 0 {
201+ return 0, nil, fmt.Errorf("%w: truncated op", errUnreadable)
202+ }
203+ o := op{kind: p[0]}
204+ if o.kind != opPut && o.kind != opDelete {
205+ return 0, nil, fmt.Errorf("%w: bad op kind %d", errUnreadable, o.kind)
206+ }
207+ var ok bool
208+ if o.key, p, ok = cutBytes(p[1:], maxKeyLen); !ok || len(o.key) == 0 {
209+ return 0, nil, fmt.Errorf("%w: bad key", errUnreadable)
210+ }
211+ if o.kind == opPut {
212+ if o.value, p, ok = cutBytes(p, maxRecord); !ok {
213+ return 0, nil, fmt.Errorf("%w: bad value", errUnreadable)
214+ }
176 }215 }
216+ ops = append(ops, o)
177 }217 }
178- ops = append(ops, o)218+ batches = append(batches, ops)
179 }219 }
180 if len(p) != 0 {220 if len(p) != 0 {
181 return 0, nil, fmt.Errorf("%w: %d trailing payload bytes", errUnreadable, len(p))221 return 0, nil, fmt.Errorf("%w: %d trailing payload bytes", errUnreadable, len(p))
182 }222 }
183- return seq, ops, nil223+ return seq, batches, nil
184 }224 }
185 225
186 // cutBytes reads a uvarint length (at most limit) and that many bytes from226 // cutBytes reads a uvarint length (at most limit) and that many bytes from
M record_test.go +83 -36
@@ -28,28 +28,46 @@ func opsEqual(a, b []op) bool {
2828
2929 func TestRecordRoundTrip(t *testing.T) {
3030 ops := sampleOps()
31- enc := appendRecord([]byte("prefix"), 42, ops)[6:]
31+ second := []op{{kind: opDelete, key: []byte("z")}}
32+ enc := appendRecord([]byte("prefix"), 42, ops, second, ops[:1])[6:]
3233 size := 0
33- for _, o := range ops {
34- size += opSize(o)
34+ for _, b := range [][]op{ops, second, ops[:1]} {
35+ size += batchHeader
36+ for _, o := range b {
37+ size += opSize(o)
38+ }
3539 }
36- if want := recordFrame + recordMinBody + size; len(enc) != want {
40+ if want := recordFrame + recordHead + size; len(enc) != want {
3741 t.Fatalf("encoded size = %d, want %d", len(enc), want)
3842 }
39- seq, got, n, err := decodeRecord(append(enc, 0xff, 0xee), nil)
43+ seq, got, n, err := decodeRecord(append(enc, 0xff, 0xee))
4044 if err != nil {
4145 t.Fatal(err)
4246 }
43- if seq != 42 || n != len(enc) || !opsEqual(got, ops) {
44- t.Fatalf("got seq=%d n=%d ops=%v", seq, n, got)
47+ if seq != 42 || n != len(enc) || len(got) != 3 ||
48+ !opsEqual(got[0], ops) || !opsEqual(got[1], second) || !opsEqual(got[2], ops[:1]) {
49+ t.Fatalf("got seq=%d n=%d batches=%v", seq, n, got)
4550 }
46- owned := ownOps(got)
51+ owned := ownOps(got[0])
4752 clear(enc)
4853 if !opsEqual(owned, ops) {
4954 t.Fatal("ownOps aliases the source buffer")
5055 }
5156 }
5257
58+// TestRecordBuilder checks that beginRecord/appendBatch/finishRecord produce
59+// the same bytes as appendRecord.
60+func TestRecordBuilder(t *testing.T) {
61+ ops := sampleOps()
62+ rec := beginRecord(nil, 7)
63+ rec = appendBatch(rec, ops)
64+ rec = appendBatch(rec, ops[:2])
65+ finishRecord(rec, 2)
66+ if want := appendRecord(nil, 7, ops, ops[:2]); !bytes.Equal(rec, want) {
67+ t.Fatal("builder output differs from appendRecord")
68+ }
69+}
70+
5371 func TestLogHeader(t *testing.T) {
5472 h := appendLogHeader(nil, 0x0102030405)
5573 if len(h) != logHeaderSize {
@@ -77,43 +95,62 @@ func rawRecord(payload []byte) []byte {
7795 return append(b, payload...)
7896 }
7997
80-func rawPayload(seq uint64, count uint32, body ...byte) []byte {
98+// rawPayload builds a payload from the seq, the batch count and raw batch
99+// bytes.
100+func rawPayload(seq uint64, batches uint32, body ...byte) []byte {
81101 p := binary.LittleEndian.AppendUint64(nil, seq)
82- p = binary.LittleEndian.AppendUint32(p, count)
102+ p = binary.LittleEndian.AppendUint32(p, batches)
83103 return append(p, body...)
84104 }
85105
106+// rawBatch encodes a batch header and raw op bytes.
107+func rawBatch(count uint32, ops ...byte) []byte {
108+ return append(binary.LittleEndian.AppendUint32(nil, count), ops...)
109+}
110+
111+func cat(parts ...[]byte) []byte { return bytes.Join(parts, nil) }
112+
86113 func TestDecodeRecordUnreadable(t *testing.T) {
87- good := appendRecord(nil, 1, []op{{kind: opPut, key: []byte("k"), value: []byte("v")}})
114+ one := []op{{kind: opPut, key: []byte("k"), value: []byte("v")}}
115+ good := appendRecord(nil, 1, one)
88116 badCRC := slices.Clone(good)
89117 badCRC[len(badCRC)-1] ^= 1
90118 tooBig := binary.LittleEndian.AppendUint32(nil, maxRecord+1)
91119 tooBig = append(tooBig, make([]byte, 16)...)
120+ put := rawBatch(1, opPut, 1, 'k', 1, 'v')
121+ del := rawBatch(1, opDelete, 1, 'k')
92122 cases := map[string][]byte{
93- "empty": nil,
94- "short frame": good[:7],
95- "short payload": good[:len(good)-1],
96- "bad crc": badCRC,
97- "len too small": rawRecord(make([]byte, 11)),
98- "len too big": tooBig,
99- "zero count": rawRecord(rawPayload(1, 0)),
100- "count too big": rawRecord(rawPayload(1, 2, opPut, 1, 'k', 1, 'v')),
101- "count too small": rawRecord(rawPayload(1, 1, opPut, 1, 'k', 1, 'v', opDelete, 1, 'k')),
102- "bad kind": rawRecord(rawPayload(1, 1, 3, 1, 'k')),
103- "empty key": rawRecord(rawPayload(1, 1, opDelete, 0, 0)),
104- "key past end": rawRecord(rawPayload(1, 1, opDelete, 9, 'k')),
105- "value past end": rawRecord(rawPayload(1, 1, opPut, 1, 'k', 5, 'v')),
106- "trailing byte": rawRecord(rawPayload(1, 1, opDelete, 1, 'k', 0)),
107- "huge count": rawRecord(rawPayload(1, 0xffffffff, opDelete, 1, 'k')),
123+ "empty": nil,
124+ "short frame": good[:7],
125+ "short payload": good[:len(good)-1],
126+ "bad crc": badCRC,
127+ "len too small": rawRecord(make([]byte, 15)),
128+ "len too big": tooBig,
129+ "zero batches": rawRecord(rawPayload(1, 0, put...)),
130+ "batches too big": rawRecord(rawPayload(1, 2, put...)),
131+ "batches too few": rawRecord(rawPayload(1, 1, cat(put, del)...)),
132+ "zero ops": rawRecord(rawPayload(1, 1, rawBatch(0, opDelete, 1, 'k')...)),
133+ "zero ops 2nd": rawRecord(rawPayload(1, 2, cat(put, rawBatch(0))...)),
134+ "ops too many": rawRecord(rawPayload(1, 1, rawBatch(2, opPut, 1, 'k', 1, 'v')...)),
135+ "ops too few": rawRecord(rawPayload(1, 1, rawBatch(1, opPut, 1, 'k', 1, 'v', opDelete, 1, 'k')...)),
136+ "bad kind": rawRecord(rawPayload(1, 1, rawBatch(1, 3, 1, 'k')...)),
137+ "empty key": rawRecord(rawPayload(1, 1, rawBatch(1, opDelete, 0, 0)...)),
138+ "key past end": rawRecord(rawPayload(1, 1, rawBatch(1, opDelete, 9, 'k')...)),
139+ "value past end": rawRecord(rawPayload(1, 1, rawBatch(1, opPut, 1, 'k', 5, 'v')...)),
140+ "trailing byte": rawRecord(rawPayload(1, 1, cat(del, []byte{0})...)),
141+ "huge batches": rawRecord(rawPayload(1, 0xffffffff, del...)),
142+ "huge op count": rawRecord(rawPayload(1, 1, rawBatch(0xffffffff, opDelete, 1, 'k')...)),
143+ "truncated batch": rawRecord(rawPayload(1, 2, cat(del, []byte{1, 0})...)),
144+ "second batch bad": rawRecord(rawPayload(1, 2, cat(del, rawBatch(1, 9, 1, 'k'))...)),
108145 }
109146 for name, b := range cases {
110147 t.Run(name, func(t *testing.T) {
111- if _, _, _, err := decodeRecord(b, nil); !errors.Is(err, errUnreadable) {
148+ if _, _, _, err := decodeRecord(b); !errors.Is(err, errUnreadable) {
112149 t.Fatalf("err = %v, want errUnreadable", err)
113150 }
114151 })
115152 }
116- if _, _, _, err := decodeRecord(rawRecord(rawPayload(1, 1, opDelete, 1, 'k')), nil); err != nil {
153+ if _, _, _, err := decodeRecord(rawRecord(rawPayload(1, 1, del...))); err != nil {
117154 t.Fatalf("control record: %v", err)
118155 }
119156 }
@@ -209,7 +246,6 @@ func TestSnapshotStructure(t *testing.T) {
209246 b = binary.AppendUvarint(b, uint64(len(v)))
210247 return append(b, v...)
211248 }
212- cat := func(parts ...[]byte) []byte { return bytes.Join(parts, nil) }
213249 cases := map[string][]byte{
214250 "not ascending": forgeSnapshot(5, 2, cat(entry("b", 1, ""), entry("a", 1, ""))),
215251 "duplicate key": forgeSnapshot(5, 2, cat(entry("a", 1, ""), entry("a", 2, ""))),
@@ -259,8 +295,9 @@ func TestWriteSnapshotRejectsBadInput(t *testing.T) {
259295
260296 func FuzzDecodeRecord(f *testing.F) {
261297 f.Add(appendRecord(nil, 1, sampleOps()))
298+ f.Add(appendRecord(nil, 1, sampleOps(), sampleOps()[:2]))
262299 f.Add(appendRecord(nil, 1<<40, []op{{kind: opDelete, key: []byte("k")}}))
263- f.Add(rawRecord(rawPayload(1, 1, opDelete, 1, 'k')))
300+ f.Add(rawRecord(rawPayload(1, 1, rawBatch(1, opDelete, 1, 'k')...)))
264301 f.Add([]byte{})
265302 f.Add(make([]byte, 64))
266303 f.Add(appendLogHeader(nil, 7))
@@ -272,19 +309,29 @@ func FuzzDecodeRecord(f *testing.F) {
272309 view.each(func(k, v []byte, ver uint64) {})
273310 }
274311 peekRecord(b)
275- seq, ops, n, err := decodeRecord(b, nil)
312+ seq, batches, n, err := decodeRecord(b)
276313 if err != nil {
277314 return
278315 }
279316 // A readable record survives re-encoding (varints may be
280317 // non-canonical in the input, so the bytes can differ).
281- if n > len(b) || len(ops) == 0 {
282- t.Fatalf("n=%d len=%d ops=%d", n, len(b), len(ops))
318+ if n > len(b) || len(batches) == 0 {
319+ t.Fatalf("n=%d len=%d batches=%d", n, len(b), len(batches))
320+ }
321+ for _, ops := range batches {
322+ if len(ops) == 0 {
323+ t.Fatal("readable record with an empty batch")
324+ }
283325 }
284- enc := appendRecord(nil, seq, ops)
285- seq2, ops2, n2, err := decodeRecord(enc, nil)
286- if err != nil || seq2 != seq || n2 != len(enc) || !opsEqual(ops2, ops) {
326+ enc := appendRecord(nil, seq, batches...)
327+ seq2, batches2, n2, err := decodeRecord(enc)
328+ if err != nil || seq2 != seq || n2 != len(enc) || len(batches2) != len(batches) {
287329 t.Fatalf("round trip failed: err=%v", err)
288330 }
331+ for i := range batches {
332+ if !opsEqual(batches[i], batches2[i]) {
333+ t.Fatalf("batch %d differs after round trip", i)
334+ }
335+ }
289336 })
290337 }
@@ -28,28 +28,46 @@ func opsEqual(a, b []op) bool {
28 28
29 func TestRecordRoundTrip(t *testing.T) {29 func TestRecordRoundTrip(t *testing.T) {
30 ops := sampleOps()30 ops := sampleOps()
31- enc := appendRecord([]byte("prefix"), 42, ops)[6:]31+ second := []op{{kind: opDelete, key: []byte("z")}}
32+ enc := appendRecord([]byte("prefix"), 42, ops, second, ops[:1])[6:]
32 size := 033 size := 0
33- for _, o := range ops {34+ for _, b := range [][]op{ops, second, ops[:1]} {
34- size += opSize(o)35+ size += batchHeader
36+ for _, o := range b {
37+ size += opSize(o)
38+ }
35 }39 }
36- if want := recordFrame + recordMinBody + size; len(enc) != want {40+ if want := recordFrame + recordHead + size; len(enc) != want {
37 t.Fatalf("encoded size = %d, want %d", len(enc), want)41 t.Fatalf("encoded size = %d, want %d", len(enc), want)
38 }42 }
39- seq, got, n, err := decodeRecord(append(enc, 0xff, 0xee), nil)43+ seq, got, n, err := decodeRecord(append(enc, 0xff, 0xee))
40 if err != nil {44 if err != nil {
41 t.Fatal(err)45 t.Fatal(err)
42 }46 }
43- if seq != 42 || n != len(enc) || !opsEqual(got, ops) {47+ if seq != 42 || n != len(enc) || len(got) != 3 ||
44- t.Fatalf("got seq=%d n=%d ops=%v", seq, n, got)48+ !opsEqual(got[0], ops) || !opsEqual(got[1], second) || !opsEqual(got[2], ops[:1]) {
49+ t.Fatalf("got seq=%d n=%d batches=%v", seq, n, got)
45 }50 }
46- owned := ownOps(got)51+ owned := ownOps(got[0])
47 clear(enc)52 clear(enc)
48 if !opsEqual(owned, ops) {53 if !opsEqual(owned, ops) {
49 t.Fatal("ownOps aliases the source buffer")54 t.Fatal("ownOps aliases the source buffer")
50 }55 }
51 }56 }
52 57
58+// TestRecordBuilder checks that beginRecord/appendBatch/finishRecord produce
59+// the same bytes as appendRecord.
60+func TestRecordBuilder(t *testing.T) {
61+ ops := sampleOps()
62+ rec := beginRecord(nil, 7)
63+ rec = appendBatch(rec, ops)
64+ rec = appendBatch(rec, ops[:2])
65+ finishRecord(rec, 2)
66+ if want := appendRecord(nil, 7, ops, ops[:2]); !bytes.Equal(rec, want) {
67+ t.Fatal("builder output differs from appendRecord")
68+ }
69+}
70+
53 func TestLogHeader(t *testing.T) {71 func TestLogHeader(t *testing.T) {
54 h := appendLogHeader(nil, 0x0102030405)72 h := appendLogHeader(nil, 0x0102030405)
55 if len(h) != logHeaderSize {73 if len(h) != logHeaderSize {
@@ -77,43 +95,62 @@ func rawRecord(payload []byte) []byte {
77 return append(b, payload...)95 return append(b, payload...)
78 }96 }
79 97
80-func rawPayload(seq uint64, count uint32, body ...byte) []byte {98+// rawPayload builds a payload from the seq, the batch count and raw batch
99+// bytes.
100+func rawPayload(seq uint64, batches uint32, body ...byte) []byte {
81 p := binary.LittleEndian.AppendUint64(nil, seq)101 p := binary.LittleEndian.AppendUint64(nil, seq)
82- p = binary.LittleEndian.AppendUint32(p, count)102+ p = binary.LittleEndian.AppendUint32(p, batches)
83 return append(p, body...)103 return append(p, body...)
84 }104 }
85 105
106+// rawBatch encodes a batch header and raw op bytes.
107+func rawBatch(count uint32, ops ...byte) []byte {
108+ return append(binary.LittleEndian.AppendUint32(nil, count), ops...)
109+}
110+
111+func cat(parts ...[]byte) []byte { return bytes.Join(parts, nil) }
112+
86 func TestDecodeRecordUnreadable(t *testing.T) {113 func TestDecodeRecordUnreadable(t *testing.T) {
87- good := appendRecord(nil, 1, []op{{kind: opPut, key: []byte("k"), value: []byte("v")}})114+ one := []op{{kind: opPut, key: []byte("k"), value: []byte("v")}}
115+ good := appendRecord(nil, 1, one)
88 badCRC := slices.Clone(good)116 badCRC := slices.Clone(good)
89 badCRC[len(badCRC)-1] ^= 1117 badCRC[len(badCRC)-1] ^= 1
90 tooBig := binary.LittleEndian.AppendUint32(nil, maxRecord+1)118 tooBig := binary.LittleEndian.AppendUint32(nil, maxRecord+1)
91 tooBig = append(tooBig, make([]byte, 16)...)119 tooBig = append(tooBig, make([]byte, 16)...)
120+ put := rawBatch(1, opPut, 1, 'k', 1, 'v')
121+ del := rawBatch(1, opDelete, 1, 'k')
92 cases := map[string][]byte{122 cases := map[string][]byte{
93- "empty": nil,123+ "empty": nil,
94- "short frame": good[:7],124+ "short frame": good[:7],
95- "short payload": good[:len(good)-1],125+ "short payload": good[:len(good)-1],
96- "bad crc": badCRC,126+ "bad crc": badCRC,
97- "len too small": rawRecord(make([]byte, 11)),127+ "len too small": rawRecord(make([]byte, 15)),
98- "len too big": tooBig,128+ "len too big": tooBig,
99- "zero count": rawRecord(rawPayload(1, 0)),129+ "zero batches": rawRecord(rawPayload(1, 0, put...)),
100- "count too big": rawRecord(rawPayload(1, 2, opPut, 1, 'k', 1, 'v')),130+ "batches too big": rawRecord(rawPayload(1, 2, put...)),
101- "count too small": rawRecord(rawPayload(1, 1, opPut, 1, 'k', 1, 'v', opDelete, 1, 'k')),131+ "batches too few": rawRecord(rawPayload(1, 1, cat(put, del)...)),
102- "bad kind": rawRecord(rawPayload(1, 1, 3, 1, 'k')),132+ "zero ops": rawRecord(rawPayload(1, 1, rawBatch(0, opDelete, 1, 'k')...)),
103- "empty key": rawRecord(rawPayload(1, 1, opDelete, 0, 0)),133+ "zero ops 2nd": rawRecord(rawPayload(1, 2, cat(put, rawBatch(0))...)),
104- "key past end": rawRecord(rawPayload(1, 1, opDelete, 9, 'k')),134+ "ops too many": rawRecord(rawPayload(1, 1, rawBatch(2, opPut, 1, 'k', 1, 'v')...)),
105- "value past end": rawRecord(rawPayload(1, 1, opPut, 1, 'k', 5, 'v')),135+ "ops too few": rawRecord(rawPayload(1, 1, rawBatch(1, opPut, 1, 'k', 1, 'v', opDelete, 1, 'k')...)),
106- "trailing byte": rawRecord(rawPayload(1, 1, opDelete, 1, 'k', 0)),136+ "bad kind": rawRecord(rawPayload(1, 1, rawBatch(1, 3, 1, 'k')...)),
107- "huge count": rawRecord(rawPayload(1, 0xffffffff, opDelete, 1, 'k')),137+ "empty key": rawRecord(rawPayload(1, 1, rawBatch(1, opDelete, 0, 0)...)),
138+ "key past end": rawRecord(rawPayload(1, 1, rawBatch(1, opDelete, 9, 'k')...)),
139+ "value past end": rawRecord(rawPayload(1, 1, rawBatch(1, opPut, 1, 'k', 5, 'v')...)),
140+ "trailing byte": rawRecord(rawPayload(1, 1, cat(del, []byte{0})...)),
141+ "huge batches": rawRecord(rawPayload(1, 0xffffffff, del...)),
142+ "huge op count": rawRecord(rawPayload(1, 1, rawBatch(0xffffffff, opDelete, 1, 'k')...)),
143+ "truncated batch": rawRecord(rawPayload(1, 2, cat(del, []byte{1, 0})...)),
144+ "second batch bad": rawRecord(rawPayload(1, 2, cat(del, rawBatch(1, 9, 1, 'k'))...)),
108 }145 }
109 for name, b := range cases {146 for name, b := range cases {
110 t.Run(name, func(t *testing.T) {147 t.Run(name, func(t *testing.T) {
111- if _, _, _, err := decodeRecord(b, nil); !errors.Is(err, errUnreadable) {148+ if _, _, _, err := decodeRecord(b); !errors.Is(err, errUnreadable) {
112 t.Fatalf("err = %v, want errUnreadable", err)149 t.Fatalf("err = %v, want errUnreadable", err)
113 }150 }
114 })151 })
115 }152 }
116- if _, _, _, err := decodeRecord(rawRecord(rawPayload(1, 1, opDelete, 1, 'k')), nil); err != nil {153+ if _, _, _, err := decodeRecord(rawRecord(rawPayload(1, 1, del...))); err != nil {
117 t.Fatalf("control record: %v", err)154 t.Fatalf("control record: %v", err)
118 }155 }
119 }156 }
@@ -209,7 +246,6 @@ func TestSnapshotStructure(t *testing.T) {
209 b = binary.AppendUvarint(b, uint64(len(v)))246 b = binary.AppendUvarint(b, uint64(len(v)))
210 return append(b, v...)247 return append(b, v...)
211 }248 }
212- cat := func(parts ...[]byte) []byte { return bytes.Join(parts, nil) }
213 cases := map[string][]byte{249 cases := map[string][]byte{
214 "not ascending": forgeSnapshot(5, 2, cat(entry("b", 1, ""), entry("a", 1, ""))),250 "not ascending": forgeSnapshot(5, 2, cat(entry("b", 1, ""), entry("a", 1, ""))),
215 "duplicate key": forgeSnapshot(5, 2, cat(entry("a", 1, ""), entry("a", 2, ""))),251 "duplicate key": forgeSnapshot(5, 2, cat(entry("a", 1, ""), entry("a", 2, ""))),
@@ -259,8 +295,9 @@ func TestWriteSnapshotRejectsBadInput(t *testing.T) {
259 295
260 func FuzzDecodeRecord(f *testing.F) {296 func FuzzDecodeRecord(f *testing.F) {
261 f.Add(appendRecord(nil, 1, sampleOps()))297 f.Add(appendRecord(nil, 1, sampleOps()))
298+ f.Add(appendRecord(nil, 1, sampleOps(), sampleOps()[:2]))
262 f.Add(appendRecord(nil, 1<<40, []op{{kind: opDelete, key: []byte("k")}}))299 f.Add(appendRecord(nil, 1<<40, []op{{kind: opDelete, key: []byte("k")}}))
263- f.Add(rawRecord(rawPayload(1, 1, opDelete, 1, 'k')))300+ f.Add(rawRecord(rawPayload(1, 1, rawBatch(1, opDelete, 1, 'k')...)))
264 f.Add([]byte{})301 f.Add([]byte{})
265 f.Add(make([]byte, 64))302 f.Add(make([]byte, 64))
266 f.Add(appendLogHeader(nil, 7))303 f.Add(appendLogHeader(nil, 7))
@@ -272,19 +309,29 @@ func FuzzDecodeRecord(f *testing.F) {
272 view.each(func(k, v []byte, ver uint64) {})309 view.each(func(k, v []byte, ver uint64) {})
273 }310 }
274 peekRecord(b)311 peekRecord(b)
275- seq, ops, n, err := decodeRecord(b, nil)312+ seq, batches, n, err := decodeRecord(b)
276 if err != nil {313 if err != nil {
277 return314 return
278 }315 }
279 // A readable record survives re-encoding (varints may be316 // A readable record survives re-encoding (varints may be
280 // non-canonical in the input, so the bytes can differ).317 // non-canonical in the input, so the bytes can differ).
281- if n > len(b) || len(ops) == 0 {318+ if n > len(b) || len(batches) == 0 {
282- t.Fatalf("n=%d len=%d ops=%d", n, len(b), len(ops))319+ t.Fatalf("n=%d len=%d batches=%d", n, len(b), len(batches))
320+ }
321+ for _, ops := range batches {
322+ if len(ops) == 0 {
323+ t.Fatal("readable record with an empty batch")
324+ }
283 }325 }
284- enc := appendRecord(nil, seq, ops)326+ enc := appendRecord(nil, seq, batches...)
285- seq2, ops2, n2, err := decodeRecord(enc, nil)327+ seq2, batches2, n2, err := decodeRecord(enc)
286- if err != nil || seq2 != seq || n2 != len(enc) || !opsEqual(ops2, ops) {328+ if err != nil || seq2 != seq || n2 != len(enc) || len(batches2) != len(batches) {
287 t.Fatalf("round trip failed: err=%v", err)329 t.Fatalf("round trip failed: err=%v", err)
288 }330 }
331+ for i := range batches {
332+ if !opsEqual(batches[i], batches2[i]) {
333+ t.Fatalf("batch %d differs after round trip", i)
334+ }
335+ }
289 })336 })
290 }337 }
M recover.go +28 -27
@@ -6,13 +6,14 @@ import (
66 "errors"
77 "fmt"
88 "io"
9- "log/slog"
109 "os"
1110 "path/filepath"
1211 "slices"
1312 "strconv"
1413 "strings"
1514
15+ "github.com/bnema/zerowrap"
16+
1617 "bnema.dev/ermite-kv/internal/fsys"
1718 )
1819
@@ -60,21 +61,19 @@ type recovered struct {
6061 // the in-memory tree.
6162 //
6263 // load is called once per snapshot entry in ascending key order, only after
63-// the whole snapshot file has been validated. apply is called for each log
64-// record newer than the snapshot, in seq order. Both receive freshly
65-// allocated slices that the callee may retain but must not modify; recovery
66-// never reuses them (snapshot entries share one read-only buffer, log ops are
67-// copied per record). Either callback may be nil. A nil logger discards logs.
64+// the whole snapshot file has been validated. apply is called for each batch
65+// newer than the snapshot, in seq order (a record that straddles the snapshot
66+// seq contributes only its newer batches). The ops passed to apply are freshly
67+// allocated and the callee may retain them but must not modify them. load
68+// receives slices of one shared read-only buffer: it must copy what it keeps.
69+// Either callback may be nil. A zero logger discards logs.
6870 //
6971 // On error nothing stays open or locked. ErrLocked and ErrCorrupt are
7072 // returned wrapped.
71-func recoverDir(fx fsys.FS, dir string, logger *slog.Logger,
73+func recoverDir(fx fsys.FS, dir string, logger zerowrap.Logger,
7274 load func(key, value []byte, version uint64),
7375 apply func(seq uint64, ops []op),
7476 ) (rec recovered, err error) {
75- if logger == nil {
76- logger = slog.New(slog.DiscardHandler)
77- }
7877 dir = filepath.Clean(dir)
7978 if err := fx.MkdirAll(dir, 0o700); err != nil {
8079 return rec, err
@@ -138,7 +137,7 @@ func recoverDir(fx fsys.FS, dir string, logger *slog.Logger,
138137 err = fmt.Errorf("%w: snapshot seq %d does not match its name", ErrCorrupt, view.seq)
139138 }
140139 if err != nil {
141- logger.Warn("kv: ignoring invalid snapshot", "file", path, "err", err)
140+ logger.Warn().Err(err).Str("file", path).Msg("ignoring invalid snapshot")
142141 continue
143142 }
144143 if load != nil {
@@ -200,7 +199,7 @@ func recoverDir(fx fsys.FS, dir string, logger *slog.Logger,
200199 // the last log it is used to truncate a torn tail. It returns the size of the
201200 // log after recovery.
202201 func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovered,
203- logger *slog.Logger, apply func(seq uint64, ops []op),
202+ logger zerowrap.Logger, apply func(seq uint64, ops []op),
204203 ) (int64, error) {
205204 last := f != nil
206205 corrupt := func(off int, format string, args ...any) error {
@@ -217,20 +216,18 @@ func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovere
217216 return 0, corrupt(0, "base %d is above the last recovered seq %d", base, rec.lastSeq)
218217 }
219218
220- var ops []op
221- expected := base + 1 // seq of the next record of this file
219+ expected := base + 1 // seq of the first batch of the next record
222220 off := logHeaderSize
223221 for off < len(data) {
224- seq, decoded, n, err := decodeRecord(data[off:], ops)
222+ seq, batches, n, err := decodeRecord(data[off:])
225223 if err != nil {
226224 if !last {
227225 return 0, corrupt(off, "%v", err)
228226 }
229- // Rule 6: a readable next record means acknowledged data
230- // lies beyond the damage.
227+ // Rule 6: a readable record after the damaged one proves that
228+ // the damaged one was synced: acknowledged data is damaged.
231229 if total, ok := peekRecord(data[off:]); ok && off+total < len(data) {
232- nseq, _, _, nerr := decodeRecord(data[off+total:], nil)
233- if nerr == nil && nseq == expected+1 {
230+ if _, _, _, nerr := decodeRecord(data[off+total:]); nerr == nil {
234231 return 0, corrupt(off, "%v, followed by a valid record", err)
235232 }
236233 }
@@ -240,24 +237,28 @@ func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovere
240237 if err := f.SyncAll(); err != nil {
241238 return 0, err
242239 }
243- logger.Warn("kv: dropped torn log tail", "file", path, "offset", off, "bytes", len(data)-off)
240+ logger.Warn().Str("file", path).Int("offset", off).Int("bytes", len(data)-off).
241+ Msg("dropped torn log tail")
244242 data = data[:off]
245243 break
246244 }
247- ops = decoded[:0]
248245 if seq != expected {
249246 return 0, corrupt(off, "record seq %d, want %d", seq, expected)
250247 }
251- if seq > rec.snapSeq {
252- if seq != rec.lastSeq+1 {
253- return 0, corrupt(off, "record seq %d, want %d", seq, rec.lastSeq+1)
248+ for i, ops := range batches {
249+ s := seq + uint64(i)
250+ if s <= rec.snapSeq {
251+ continue
252+ }
253+ if s != rec.lastSeq+1 {
254+ return 0, corrupt(off, "batch seq %d, want %d", s, rec.lastSeq+1)
254255 }
255256 if apply != nil {
256- apply(seq, ownOps(decoded))
257+ apply(s, ownOps(ops))
257258 }
258- rec.lastSeq = seq
259+ rec.lastSeq = s
259260 }
260- expected++
261+ expected += uint64(len(batches))
261262 off += n
262263 }
263264 if last && expected-1 < rec.lastSeq {
@@ -6,13 +6,14 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "io"8 "io"
9- "log/slog"
10 "os"9 "os"
11 "path/filepath"10 "path/filepath"
12 "slices"11 "slices"
13 "strconv"12 "strconv"
14 "strings"13 "strings"
15 14
15+ "github.com/bnema/zerowrap"
16+
16 "bnema.dev/ermite-kv/internal/fsys"17 "bnema.dev/ermite-kv/internal/fsys"
17 )18 )
18 19
@@ -60,21 +61,19 @@ type recovered struct {
60 // the in-memory tree.61 // the in-memory tree.
61 //62 //
62 // load is called once per snapshot entry in ascending key order, only after63 // load is called once per snapshot entry in ascending key order, only after
63-// the whole snapshot file has been validated. apply is called for each log64+// the whole snapshot file has been validated. apply is called for each batch
64-// record newer than the snapshot, in seq order. Both receive freshly65+// newer than the snapshot, in seq order (a record that straddles the snapshot
65-// allocated slices that the callee may retain but must not modify; recovery66+// seq contributes only its newer batches). The ops passed to apply are freshly
66-// never reuses them (snapshot entries share one read-only buffer, log ops are67+// allocated and the callee may retain them but must not modify them. load
67-// copied per record). Either callback may be nil. A nil logger discards logs.68+// receives slices of one shared read-only buffer: it must copy what it keeps.
69+// Either callback may be nil. A zero logger discards logs.
68 //70 //
69 // On error nothing stays open or locked. ErrLocked and ErrCorrupt are71 // On error nothing stays open or locked. ErrLocked and ErrCorrupt are
70 // returned wrapped.72 // returned wrapped.
71-func recoverDir(fx fsys.FS, dir string, logger *slog.Logger,73+func recoverDir(fx fsys.FS, dir string, logger zerowrap.Logger,
72 load func(key, value []byte, version uint64),74 load func(key, value []byte, version uint64),
73 apply func(seq uint64, ops []op),75 apply func(seq uint64, ops []op),
74 ) (rec recovered, err error) {76 ) (rec recovered, err error) {
75- if logger == nil {
76- logger = slog.New(slog.DiscardHandler)
77- }
78 dir = filepath.Clean(dir)77 dir = filepath.Clean(dir)
79 if err := fx.MkdirAll(dir, 0o700); err != nil {78 if err := fx.MkdirAll(dir, 0o700); err != nil {
80 return rec, err79 return rec, err
@@ -138,7 +137,7 @@ func recoverDir(fx fsys.FS, dir string, logger *slog.Logger,
138 err = fmt.Errorf("%w: snapshot seq %d does not match its name", ErrCorrupt, view.seq)137 err = fmt.Errorf("%w: snapshot seq %d does not match its name", ErrCorrupt, view.seq)
139 }138 }
140 if err != nil {139 if err != nil {
141- logger.Warn("kv: ignoring invalid snapshot", "file", path, "err", err)140+ logger.Warn().Err(err).Str("file", path).Msg("ignoring invalid snapshot")
142 continue141 continue
143 }142 }
144 if load != nil {143 if load != nil {
@@ -200,7 +199,7 @@ func recoverDir(fx fsys.FS, dir string, logger *slog.Logger,
200 // the last log it is used to truncate a torn tail. It returns the size of the199 // the last log it is used to truncate a torn tail. It returns the size of the
201 // log after recovery.200 // log after recovery.
202 func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovered,201 func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovered,
203- logger *slog.Logger, apply func(seq uint64, ops []op),202+ logger zerowrap.Logger, apply func(seq uint64, ops []op),
204 ) (int64, error) {203 ) (int64, error) {
205 last := f != nil204 last := f != nil
206 corrupt := func(off int, format string, args ...any) error {205 corrupt := func(off int, format string, args ...any) error {
@@ -217,20 +216,18 @@ func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovere
217 return 0, corrupt(0, "base %d is above the last recovered seq %d", base, rec.lastSeq)216 return 0, corrupt(0, "base %d is above the last recovered seq %d", base, rec.lastSeq)
218 }217 }
219 218
220- var ops []op219+ expected := base + 1 // seq of the first batch of the next record
221- expected := base + 1 // seq of the next record of this file
222 off := logHeaderSize220 off := logHeaderSize
223 for off < len(data) {221 for off < len(data) {
224- seq, decoded, n, err := decodeRecord(data[off:], ops)222+ seq, batches, n, err := decodeRecord(data[off:])
225 if err != nil {223 if err != nil {
226 if !last {224 if !last {
227 return 0, corrupt(off, "%v", err)225 return 0, corrupt(off, "%v", err)
228 }226 }
229- // Rule 6: a readable next record means acknowledged data227+ // Rule 6: a readable record after the damaged one proves that
230- // lies beyond the damage.228+ // the damaged one was synced: acknowledged data is damaged.
231 if total, ok := peekRecord(data[off:]); ok && off+total < len(data) {229 if total, ok := peekRecord(data[off:]); ok && off+total < len(data) {
232- nseq, _, _, nerr := decodeRecord(data[off+total:], nil)230+ if _, _, _, nerr := decodeRecord(data[off+total:]); nerr == nil {
233- if nerr == nil && nseq == expected+1 {
234 return 0, corrupt(off, "%v, followed by a valid record", err)231 return 0, corrupt(off, "%v, followed by a valid record", err)
235 }232 }
236 }233 }
@@ -240,24 +237,28 @@ func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovere
240 if err := f.SyncAll(); err != nil {237 if err := f.SyncAll(); err != nil {
241 return 0, err238 return 0, err
242 }239 }
243- logger.Warn("kv: dropped torn log tail", "file", path, "offset", off, "bytes", len(data)-off)240+ logger.Warn().Str("file", path).Int("offset", off).Int("bytes", len(data)-off).
241+ Msg("dropped torn log tail")
244 data = data[:off]242 data = data[:off]
245 break243 break
246 }244 }
247- ops = decoded[:0]
248 if seq != expected {245 if seq != expected {
249 return 0, corrupt(off, "record seq %d, want %d", seq, expected)246 return 0, corrupt(off, "record seq %d, want %d", seq, expected)
250 }247 }
251- if seq > rec.snapSeq {248+ for i, ops := range batches {
252- if seq != rec.lastSeq+1 {249+ s := seq + uint64(i)
253- return 0, corrupt(off, "record seq %d, want %d", seq, rec.lastSeq+1)250+ if s <= rec.snapSeq {
251+ continue
252+ }
253+ if s != rec.lastSeq+1 {
254+ return 0, corrupt(off, "batch seq %d, want %d", s, rec.lastSeq+1)
254 }255 }
255 if apply != nil {256 if apply != nil {
256- apply(seq, ownOps(decoded))257+ apply(s, ownOps(ops))
257 }258 }
258- rec.lastSeq = seq259+ rec.lastSeq = s
259 }260 }
260- expected++261+ expected += uint64(len(batches))
261 off += n262 off += n
262 }263 }
263 if last && expected-1 < rec.lastSeq {264 if last && expected-1 < rec.lastSeq {
M recover_test.go +125 -19
@@ -4,14 +4,17 @@ package kv
44
55 import (
66 "bytes"
7+ "context"
78 "errors"
89 "fmt"
910 "io"
10- "log/slog"
1111 "path/filepath"
12+ "slices"
1213 "strings"
1314 "testing"
1415
16+ "github.com/bnema/zerowrap"
17+
1518 "bnema.dev/ermite-kv/internal/fsys"
1619 "bnema.dev/ermite-kv/internal/fsys/faultfs"
1720 )
@@ -44,7 +47,15 @@ func (s *state) apply(seq uint64, ops []op) {
4447 }
4548 }
4649
47-func recoverState(t *testing.T, fx fsys.FS, logger *slog.Logger) (*state, recovered, error) {
50+// noLog is a disabled logger.
51+var noLog = zerowrap.FromCtx(context.Background())
52+
53+// bufLogger returns a JSON logger at debug level writing to buf.
54+func bufLogger(buf *bytes.Buffer) zerowrap.Logger {
55+ return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: buf})
56+}
57+
58+func recoverState(t *testing.T, fx fsys.FS, logger zerowrap.Logger) (*state, recovered, error) {
4859 t.Helper()
4960 s := &state{m: map[string]string{}, versions: map[string]uint64{}}
5061 rec, err := recoverDir(fx, testDir, logger, s.load, s.apply)
@@ -55,7 +66,7 @@ func recoverState(t *testing.T, fx fsys.FS, logger *slog.Logger) (*state, recove
5566 // releases the lock and the log file.
5667 func mustRecover(t *testing.T, fx fsys.FS) (*state, recovered) {
5768 t.Helper()
58- s, rec, err := recoverState(t, fx, nil)
69+ s, rec, err := recoverState(t, fx, noLog)
5970 if err != nil {
6071 t.Fatalf("recoverDir: %v", err)
6172 }
@@ -134,12 +145,12 @@ func TestRecoverEmptyDirCreatesLog(t *testing.T) {
134145 func TestRecoverLockedAndRelease(t *testing.T) {
135146 fx := faultfs.New()
136147 _, rec := mustRecover(t, fx)
137- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrLocked) {
148+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrLocked) {
138149 t.Fatalf("second recover: err = %v, want ErrLocked", err)
139150 }
140151 _ = rec.log.Close()
141152 _ = rec.lock.Close()
142- _, rec2, err := recoverState(t, fx, nil)
153+ _, rec2, err := recoverState(t, fx, noLog)
143154 if err != nil {
144155 t.Fatalf("recover after release: %v", err)
145156 }
@@ -150,7 +161,7 @@ func TestRecoverLockedAndRelease(t *testing.T) {
150161 func TestRecoverReleasesLockOnError(t *testing.T) {
151162 fx := faultfs.New()
152163 put(fx, logName(0), []byte("garbage"))
153- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {
164+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
154165 t.Fatalf("err = %v, want ErrCorrupt", err)
155166 }
156167 l, err := fx.Lock(filepath.Join(testDir, lockName))
@@ -228,7 +239,7 @@ func TestRecoverTornTail(t *testing.T) {
228239 fx := faultfs.New()
229240 put(fx, logName(0), append(append([]byte(nil), good...), tail...))
230241 var logs bytes.Buffer
231- s, rec, err := recoverState(t, fx, slog.New(slog.NewTextHandler(&logs, nil)))
242+ s, rec, err := recoverState(t, fx, bufLogger(&logs))
232243 if err != nil {
233244 t.Fatal(err)
234245 }
@@ -270,20 +281,19 @@ func TestRecoverDamageFollowedByValidRecord(t *testing.T) {
270281 return c
271282 }
272283 hdr := appendLogHeader(nil, 0)
273- cat := func(parts ...[]byte) []byte { return bytes.Join(parts, nil) }
274284 cases := map[string][]byte{
275285 "bad crc in middle record": cat(hdr, r1, flip(r2, len(r2)-1), r3),
276286 "bad payload in first record": cat(hdr, flip(r1, 9), r2),
277287 "bad checksum field": cat(hdr, r1, flip(r2, 4), r3),
278288 "damaged record before last": cat(hdr, flip(r1, len(r1)-1), r2, r3),
279- "record with wrong count": cat(hdr, rawRecord(rawPayload(1, 5, opDelete, 1, 'k')), r2),
289+ "record with wrong count": cat(hdr, rawRecord(rawPayload(1, 5, rawBatch(1, opDelete, 1, 'k')...)), r2),
280290 "corrupt but len looks intact": cat(hdr, r1, flip(r2, 12), r3),
281291 }
282292 for name, data := range cases {
283293 t.Run(name, func(t *testing.T) {
284294 fx := faultfs.New()
285295 put(fx, logName(0), data)
286- _, _, err := recoverState(t, fx, nil)
296+ _, _, err := recoverState(t, fx, noLog)
287297 if !errors.Is(err, ErrCorrupt) {
288298 t.Fatalf("err = %v, want ErrCorrupt", err)
289299 }
@@ -308,7 +318,7 @@ func TestRecoverDamageInOlderLog(t *testing.T) {
308318 fx := faultfs.New()
309319 put(fx, logName(0), data)
310320 put(fx, logName(2), logBytes(2, []op{putOp("c", "3")}))
311- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {
321+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
312322 t.Fatalf("err = %v, want ErrCorrupt", err)
313323 }
314324 })
@@ -338,7 +348,7 @@ func TestRecoverBadLogHeader(t *testing.T) {
338348 t.Run(name, func(t *testing.T) {
339349 fx := faultfs.New()
340350 put(fx, c.name, c.data)
341- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {
351+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
342352 t.Fatalf("err = %v, want ErrCorrupt", err)
343353 }
344354 })
@@ -347,7 +357,7 @@ func TestRecoverBadLogHeader(t *testing.T) {
347357 fx := faultfs.New()
348358 put(fx, logName(0), good[:10])
349359 put(fx, logName(1), logBytes(1, []op{putOp("b", "2")}))
350- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {
360+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
351361 t.Fatalf("err = %v, want ErrCorrupt", err)
352362 }
353363 })
@@ -391,7 +401,7 @@ func TestRecoverSeqGap(t *testing.T) {
391401 t.Run(name, func(t *testing.T) {
392402 fx := faultfs.New()
393403 setup(fx)
394- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {
404+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
395405 t.Fatalf("err = %v, want ErrCorrupt", err)
396406 }
397407 })
@@ -444,7 +454,7 @@ func TestRecoverInvalidNewestSnapshotFallsBack(t *testing.T) {
444454 put(fx, snapshotName(4), bad)
445455 put(fx, logName(2), logBytes(2, []op{putOp("c", "3")}, []op{putOp("d", "4")}, []op{delOp("a")}))
446456 var logs bytes.Buffer
447- s, rec, err := recoverState(t, fx, slog.New(slog.NewTextHandler(&logs, nil)))
457+ s, rec, err := recoverState(t, fx, bufLogger(&logs))
448458 if err != nil {
449459 t.Fatal(err)
450460 }
@@ -489,7 +499,7 @@ func TestRecoverSnapshotLoadOrderAndOwnership(t *testing.T) {
489499 put(fx, snapshotName(1), snapshotBytes(t, 1, snapEnt("a", "1", 1), snapEnt("b", "2", 1), snapEnt("c", "3", 1)))
490500 var keys []string
491501 var held [][]byte
492- _, err := recoverDir(fx, testDir, nil, func(k, v []byte, ver uint64) {
502+ _, err := recoverDir(fx, testDir, noLog, func(k, v []byte, ver uint64) {
493503 keys = append(keys, string(k))
494504 held = append(held, k, v)
495505 }, nil)
@@ -528,11 +538,11 @@ func TestRecoverOSFilesystem(t *testing.T) {
528538 data := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")})
529539 torn := append(append([]byte(nil), data...), 9, 9, 9)
530540
531- rec, err := recoverDir(fsys.OS(), dir, nil, nil, nil)
541+ rec, err := recoverDir(fsys.OS(), dir, noLog, nil, nil)
532542 if err != nil {
533543 t.Fatal(err)
534544 }
535- if _, err := recoverDir(fsys.OS(), dir, nil, nil, nil); !errors.Is(err, ErrLocked) {
545+ if _, err := recoverDir(fsys.OS(), dir, noLog, nil, nil); !errors.Is(err, ErrLocked) {
536546 t.Fatalf("second recover: err = %v, want ErrLocked", err)
537547 }
538548 if _, err := rec.log.Write(data[logHeaderSize:]); err != nil {
@@ -548,7 +558,7 @@ func TestRecoverOSFilesystem(t *testing.T) {
548558 _ = rec.lock.Close()
549559
550560 s := &state{m: map[string]string{}, versions: map[string]uint64{}}
551- rec, err = recoverDir(fsys.OS(), dir, nil, s.load, s.apply)
561+ rec, err = recoverDir(fsys.OS(), dir, noLog, s.load, s.apply)
552562 if err != nil {
553563 t.Fatal(err)
554564 }
@@ -563,3 +573,99 @@ func TestRecoverOSFilesystem(t *testing.T) {
563573 t.Fatalf("ReadAt = %d, %v: tail not truncated", n, err)
564574 }
565575 }
576+
577+func TestRecoverGroupRecords(t *testing.T) {
578+ fx := faultfs.New()
579+ b := appendLogHeader(nil, 0)
580+ b = appendRecord(b, 1, []op{putOp("a", "1")}, []op{putOp("b", "2"), delOp("a")}, []op{putOp("c", "3")})
581+ b = appendRecord(b, 4, []op{putOp("a", "4")})
582+ put(fx, logName(0), b)
583+ s, rec := mustRecover(t, fx)
584+ wantState(t, s, map[string]string{"a": "4", "b": "2", "c": "3"})
585+ if got := fmt.Sprint(s.seqs); got != "[1 2 3 4]" {
586+ t.Fatalf("applied seqs %s", got)
587+ }
588+ if s.versions["b"] != 2 || s.versions["c"] != 3 || s.versions["a"] != 4 || rec.lastSeq != 4 {
589+ t.Fatalf("versions = %v, rec = %+v", s.versions, rec)
590+ }
591+}
592+
593+// TestRecoverRecordStraddlesSnapshot checks that only the batches newer than
594+// the snapshot of a record that spans the snapshot seq are replayed.
595+func TestRecoverRecordStraddlesSnapshot(t *testing.T) {
596+ fx := faultfs.New()
597+ put(fx, snapshotName(2), snapshotBytes(t, 2, snapEnt("a", "1", 1), snapEnt("b", "2", 2)))
598+ b := appendLogHeader(nil, 0)
599+ b = appendRecord(b, 1, []op{putOp("a", "1")}, []op{putOp("b", "2")}, []op{putOp("c", "3")}, []op{delOp("a")})
600+ b = appendRecord(b, 5, []op{putOp("d", "5")})
601+ put(fx, logName(0), b)
602+ s, rec := mustRecover(t, fx)
603+ wantState(t, s, map[string]string{"b": "2", "c": "3", "d": "5"})
604+ if got := fmt.Sprint(s.seqs); got != "[3 4 5]" {
605+ t.Fatalf("applied seqs %s, want [3 4 5]", got)
606+ }
607+ if rec.snapSeq != 2 || rec.lastSeq != 5 {
608+ t.Fatalf("rec = %+v", rec)
609+ }
610+
611+ // A record that ends exactly at the snapshot seq adds nothing.
612+ fx = faultfs.New()
613+ put(fx, snapshotName(2), snapshotBytes(t, 2, snapEnt("a", "1", 1), snapEnt("b", "2", 2)))
614+ put(fx, logName(0), cat(appendLogHeader(nil, 0), appendRecord(nil, 1, []op{putOp("a", "1")}, []op{putOp("b", "2")})))
615+ s, rec = mustRecover(t, fx)
616+ if len(s.seqs) != 0 || rec.lastSeq != 2 {
617+ t.Fatalf("applied %v, rec = %+v", s.seqs, rec)
618+ }
619+}
620+
621+func TestRecoverTornGroupRecordFollowedByValidRecord(t *testing.T) {
622+ r1 := appendRecord(nil, 1, []op{putOp("a", "1")}, []op{putOp("b", "2")})
623+ r2 := appendRecord(nil, 3, []op{putOp("c", "3")}, []op{putOp("d", "4")})
624+ r3 := appendRecord(nil, 5, []op{putOp("e", "5")})
625+ damaged := slices.Clone(r2)
626+ damaged[len(damaged)/2] ^= 0xff
627+ fx := faultfs.New()
628+ data := cat(appendLogHeader(nil, 0), r1, damaged, r3)
629+ put(fx, logName(0), data)
630+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
631+ t.Fatalf("err = %v, want ErrCorrupt", err)
632+ }
633+ if got, _ := fx.ReadFile(filepath.Join(testDir, logName(0))); !bytes.Equal(got, data) {
634+ t.Fatal("corrupt log was modified")
635+ }
636+
637+ // Without a readable record after it, the same damage is a torn tail
638+ // that drops the whole group record, and nothing of it is applied.
639+ fx = faultfs.New()
640+ put(fx, logName(0), cat(appendLogHeader(nil, 0), r1, damaged))
641+ s, rec := mustRecover(t, fx)
642+ wantState(t, s, map[string]string{"a": "1", "b": "2"})
643+ if rec.lastSeq != 2 {
644+ t.Fatalf("rec = %+v", rec)
645+ }
646+}
647+
648+// TestRecoverDamagedLenIsTornTail checks that a damaged len field of the last
649+// record is handled as a torn tail: the record is dropped.
650+func TestRecoverDamagedLenIsTornTail(t *testing.T) {
651+ good := logBytes(0, []op{putOp("a", "1")})
652+ r2 := appendRecord(nil, 2, []op{putOp("b", "2")})
653+ for name, patch := range map[string]func(b []byte){
654+ "len beyond EOF": func(b []byte) { b[1] = 0x7f },
655+ "len above max": func(b []byte) { b[3] = 0x7f },
656+ "len below min": func(b []byte) { clear(b[:4]) },
657+ "len off by some": func(b []byte) { b[0] += 3 },
658+ } {
659+ t.Run(name, func(t *testing.T) {
660+ bad := slices.Clone(r2)
661+ patch(bad)
662+ fx := faultfs.New()
663+ put(fx, logName(0), cat(good, bad))
664+ s, rec := mustRecover(t, fx)
665+ wantState(t, s, map[string]string{"a": "1"})
666+ if rec.lastSeq != 1 || rec.logSize != int64(len(good)) {
667+ t.Fatalf("rec = %+v", rec)
668+ }
669+ })
670+ }
671+}
@@ -4,14 +4,17 @@ package kv
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7+ "context"
7 "errors"8 "errors"
8 "fmt"9 "fmt"
9 "io"10 "io"
10- "log/slog"
11 "path/filepath"11 "path/filepath"
12+ "slices"
12 "strings"13 "strings"
13 "testing"14 "testing"
14 15
16+ "github.com/bnema/zerowrap"
17+
15 "bnema.dev/ermite-kv/internal/fsys"18 "bnema.dev/ermite-kv/internal/fsys"
16 "bnema.dev/ermite-kv/internal/fsys/faultfs"19 "bnema.dev/ermite-kv/internal/fsys/faultfs"
17 )20 )
@@ -44,7 +47,15 @@ func (s *state) apply(seq uint64, ops []op) {
44 }47 }
45 }48 }
46 49
47-func recoverState(t *testing.T, fx fsys.FS, logger *slog.Logger) (*state, recovered, error) {50+// noLog is a disabled logger.
51+var noLog = zerowrap.FromCtx(context.Background())
52+
53+// bufLogger returns a JSON logger at debug level writing to buf.
54+func bufLogger(buf *bytes.Buffer) zerowrap.Logger {
55+ return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: buf})
56+}
57+
58+func recoverState(t *testing.T, fx fsys.FS, logger zerowrap.Logger) (*state, recovered, error) {
48 t.Helper()59 t.Helper()
49 s := &state{m: map[string]string{}, versions: map[string]uint64{}}60 s := &state{m: map[string]string{}, versions: map[string]uint64{}}
50 rec, err := recoverDir(fx, testDir, logger, s.load, s.apply)61 rec, err := recoverDir(fx, testDir, logger, s.load, s.apply)
@@ -55,7 +66,7 @@ func recoverState(t *testing.T, fx fsys.FS, logger *slog.Logger) (*state, recove
55 // releases the lock and the log file.66 // releases the lock and the log file.
56 func mustRecover(t *testing.T, fx fsys.FS) (*state, recovered) {67 func mustRecover(t *testing.T, fx fsys.FS) (*state, recovered) {
57 t.Helper()68 t.Helper()
58- s, rec, err := recoverState(t, fx, nil)69+ s, rec, err := recoverState(t, fx, noLog)
59 if err != nil {70 if err != nil {
60 t.Fatalf("recoverDir: %v", err)71 t.Fatalf("recoverDir: %v", err)
61 }72 }
@@ -134,12 +145,12 @@ func TestRecoverEmptyDirCreatesLog(t *testing.T) {
134 func TestRecoverLockedAndRelease(t *testing.T) {145 func TestRecoverLockedAndRelease(t *testing.T) {
135 fx := faultfs.New()146 fx := faultfs.New()
136 _, rec := mustRecover(t, fx)147 _, rec := mustRecover(t, fx)
137- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrLocked) {148+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrLocked) {
138 t.Fatalf("second recover: err = %v, want ErrLocked", err)149 t.Fatalf("second recover: err = %v, want ErrLocked", err)
139 }150 }
140 _ = rec.log.Close()151 _ = rec.log.Close()
141 _ = rec.lock.Close()152 _ = rec.lock.Close()
142- _, rec2, err := recoverState(t, fx, nil)153+ _, rec2, err := recoverState(t, fx, noLog)
143 if err != nil {154 if err != nil {
144 t.Fatalf("recover after release: %v", err)155 t.Fatalf("recover after release: %v", err)
145 }156 }
@@ -150,7 +161,7 @@ func TestRecoverLockedAndRelease(t *testing.T) {
150 func TestRecoverReleasesLockOnError(t *testing.T) {161 func TestRecoverReleasesLockOnError(t *testing.T) {
151 fx := faultfs.New()162 fx := faultfs.New()
152 put(fx, logName(0), []byte("garbage"))163 put(fx, logName(0), []byte("garbage"))
153- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {164+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
154 t.Fatalf("err = %v, want ErrCorrupt", err)165 t.Fatalf("err = %v, want ErrCorrupt", err)
155 }166 }
156 l, err := fx.Lock(filepath.Join(testDir, lockName))167 l, err := fx.Lock(filepath.Join(testDir, lockName))
@@ -228,7 +239,7 @@ func TestRecoverTornTail(t *testing.T) {
228 fx := faultfs.New()239 fx := faultfs.New()
229 put(fx, logName(0), append(append([]byte(nil), good...), tail...))240 put(fx, logName(0), append(append([]byte(nil), good...), tail...))
230 var logs bytes.Buffer241 var logs bytes.Buffer
231- s, rec, err := recoverState(t, fx, slog.New(slog.NewTextHandler(&logs, nil)))242+ s, rec, err := recoverState(t, fx, bufLogger(&logs))
232 if err != nil {243 if err != nil {
233 t.Fatal(err)244 t.Fatal(err)
234 }245 }
@@ -270,20 +281,19 @@ func TestRecoverDamageFollowedByValidRecord(t *testing.T) {
270 return c281 return c
271 }282 }
272 hdr := appendLogHeader(nil, 0)283 hdr := appendLogHeader(nil, 0)
273- cat := func(parts ...[]byte) []byte { return bytes.Join(parts, nil) }
274 cases := map[string][]byte{284 cases := map[string][]byte{
275 "bad crc in middle record": cat(hdr, r1, flip(r2, len(r2)-1), r3),285 "bad crc in middle record": cat(hdr, r1, flip(r2, len(r2)-1), r3),
276 "bad payload in first record": cat(hdr, flip(r1, 9), r2),286 "bad payload in first record": cat(hdr, flip(r1, 9), r2),
277 "bad checksum field": cat(hdr, r1, flip(r2, 4), r3),287 "bad checksum field": cat(hdr, r1, flip(r2, 4), r3),
278 "damaged record before last": cat(hdr, flip(r1, len(r1)-1), r2, r3),288 "damaged record before last": cat(hdr, flip(r1, len(r1)-1), r2, r3),
279- "record with wrong count": cat(hdr, rawRecord(rawPayload(1, 5, opDelete, 1, 'k')), r2),289+ "record with wrong count": cat(hdr, rawRecord(rawPayload(1, 5, rawBatch(1, opDelete, 1, 'k')...)), r2),
280 "corrupt but len looks intact": cat(hdr, r1, flip(r2, 12), r3),290 "corrupt but len looks intact": cat(hdr, r1, flip(r2, 12), r3),
281 }291 }
282 for name, data := range cases {292 for name, data := range cases {
283 t.Run(name, func(t *testing.T) {293 t.Run(name, func(t *testing.T) {
284 fx := faultfs.New()294 fx := faultfs.New()
285 put(fx, logName(0), data)295 put(fx, logName(0), data)
286- _, _, err := recoverState(t, fx, nil)296+ _, _, err := recoverState(t, fx, noLog)
287 if !errors.Is(err, ErrCorrupt) {297 if !errors.Is(err, ErrCorrupt) {
288 t.Fatalf("err = %v, want ErrCorrupt", err)298 t.Fatalf("err = %v, want ErrCorrupt", err)
289 }299 }
@@ -308,7 +318,7 @@ func TestRecoverDamageInOlderLog(t *testing.T) {
308 fx := faultfs.New()318 fx := faultfs.New()
309 put(fx, logName(0), data)319 put(fx, logName(0), data)
310 put(fx, logName(2), logBytes(2, []op{putOp("c", "3")}))320 put(fx, logName(2), logBytes(2, []op{putOp("c", "3")}))
311- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {321+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
312 t.Fatalf("err = %v, want ErrCorrupt", err)322 t.Fatalf("err = %v, want ErrCorrupt", err)
313 }323 }
314 })324 })
@@ -338,7 +348,7 @@ func TestRecoverBadLogHeader(t *testing.T) {
338 t.Run(name, func(t *testing.T) {348 t.Run(name, func(t *testing.T) {
339 fx := faultfs.New()349 fx := faultfs.New()
340 put(fx, c.name, c.data)350 put(fx, c.name, c.data)
341- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {351+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
342 t.Fatalf("err = %v, want ErrCorrupt", err)352 t.Fatalf("err = %v, want ErrCorrupt", err)
343 }353 }
344 })354 })
@@ -347,7 +357,7 @@ func TestRecoverBadLogHeader(t *testing.T) {
347 fx := faultfs.New()357 fx := faultfs.New()
348 put(fx, logName(0), good[:10])358 put(fx, logName(0), good[:10])
349 put(fx, logName(1), logBytes(1, []op{putOp("b", "2")}))359 put(fx, logName(1), logBytes(1, []op{putOp("b", "2")}))
350- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {360+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
351 t.Fatalf("err = %v, want ErrCorrupt", err)361 t.Fatalf("err = %v, want ErrCorrupt", err)
352 }362 }
353 })363 })
@@ -391,7 +401,7 @@ func TestRecoverSeqGap(t *testing.T) {
391 t.Run(name, func(t *testing.T) {401 t.Run(name, func(t *testing.T) {
392 fx := faultfs.New()402 fx := faultfs.New()
393 setup(fx)403 setup(fx)
394- if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) {404+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
395 t.Fatalf("err = %v, want ErrCorrupt", err)405 t.Fatalf("err = %v, want ErrCorrupt", err)
396 }406 }
397 })407 })
@@ -444,7 +454,7 @@ func TestRecoverInvalidNewestSnapshotFallsBack(t *testing.T) {
444 put(fx, snapshotName(4), bad)454 put(fx, snapshotName(4), bad)
445 put(fx, logName(2), logBytes(2, []op{putOp("c", "3")}, []op{putOp("d", "4")}, []op{delOp("a")}))455 put(fx, logName(2), logBytes(2, []op{putOp("c", "3")}, []op{putOp("d", "4")}, []op{delOp("a")}))
446 var logs bytes.Buffer456 var logs bytes.Buffer
447- s, rec, err := recoverState(t, fx, slog.New(slog.NewTextHandler(&logs, nil)))457+ s, rec, err := recoverState(t, fx, bufLogger(&logs))
448 if err != nil {458 if err != nil {
449 t.Fatal(err)459 t.Fatal(err)
450 }460 }
@@ -489,7 +499,7 @@ func TestRecoverSnapshotLoadOrderAndOwnership(t *testing.T) {
489 put(fx, snapshotName(1), snapshotBytes(t, 1, snapEnt("a", "1", 1), snapEnt("b", "2", 1), snapEnt("c", "3", 1)))499 put(fx, snapshotName(1), snapshotBytes(t, 1, snapEnt("a", "1", 1), snapEnt("b", "2", 1), snapEnt("c", "3", 1)))
490 var keys []string500 var keys []string
491 var held [][]byte501 var held [][]byte
492- _, err := recoverDir(fx, testDir, nil, func(k, v []byte, ver uint64) {502+ _, err := recoverDir(fx, testDir, noLog, func(k, v []byte, ver uint64) {
493 keys = append(keys, string(k))503 keys = append(keys, string(k))
494 held = append(held, k, v)504 held = append(held, k, v)
495 }, nil)505 }, nil)
@@ -528,11 +538,11 @@ func TestRecoverOSFilesystem(t *testing.T) {
528 data := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")})538 data := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")})
529 torn := append(append([]byte(nil), data...), 9, 9, 9)539 torn := append(append([]byte(nil), data...), 9, 9, 9)
530 540
531- rec, err := recoverDir(fsys.OS(), dir, nil, nil, nil)541+ rec, err := recoverDir(fsys.OS(), dir, noLog, nil, nil)
532 if err != nil {542 if err != nil {
533 t.Fatal(err)543 t.Fatal(err)
534 }544 }
535- if _, err := recoverDir(fsys.OS(), dir, nil, nil, nil); !errors.Is(err, ErrLocked) {545+ if _, err := recoverDir(fsys.OS(), dir, noLog, nil, nil); !errors.Is(err, ErrLocked) {
536 t.Fatalf("second recover: err = %v, want ErrLocked", err)546 t.Fatalf("second recover: err = %v, want ErrLocked", err)
537 }547 }
538 if _, err := rec.log.Write(data[logHeaderSize:]); err != nil {548 if _, err := rec.log.Write(data[logHeaderSize:]); err != nil {
@@ -548,7 +558,7 @@ func TestRecoverOSFilesystem(t *testing.T) {
548 _ = rec.lock.Close()558 _ = rec.lock.Close()
549 559
550 s := &state{m: map[string]string{}, versions: map[string]uint64{}}560 s := &state{m: map[string]string{}, versions: map[string]uint64{}}
551- rec, err = recoverDir(fsys.OS(), dir, nil, s.load, s.apply)561+ rec, err = recoverDir(fsys.OS(), dir, noLog, s.load, s.apply)
552 if err != nil {562 if err != nil {
553 t.Fatal(err)563 t.Fatal(err)
554 }564 }
@@ -563,3 +573,99 @@ func TestRecoverOSFilesystem(t *testing.T) {
563 t.Fatalf("ReadAt = %d, %v: tail not truncated", n, err)573 t.Fatalf("ReadAt = %d, %v: tail not truncated", n, err)
564 }574 }
565 }575 }
576+
577+func TestRecoverGroupRecords(t *testing.T) {
578+ fx := faultfs.New()
579+ b := appendLogHeader(nil, 0)
580+ b = appendRecord(b, 1, []op{putOp("a", "1")}, []op{putOp("b", "2"), delOp("a")}, []op{putOp("c", "3")})
581+ b = appendRecord(b, 4, []op{putOp("a", "4")})
582+ put(fx, logName(0), b)
583+ s, rec := mustRecover(t, fx)
584+ wantState(t, s, map[string]string{"a": "4", "b": "2", "c": "3"})
585+ if got := fmt.Sprint(s.seqs); got != "[1 2 3 4]" {
586+ t.Fatalf("applied seqs %s", got)
587+ }
588+ if s.versions["b"] != 2 || s.versions["c"] != 3 || s.versions["a"] != 4 || rec.lastSeq != 4 {
589+ t.Fatalf("versions = %v, rec = %+v", s.versions, rec)
590+ }
591+}
592+
593+// TestRecoverRecordStraddlesSnapshot checks that only the batches newer than
594+// the snapshot of a record that spans the snapshot seq are replayed.
595+func TestRecoverRecordStraddlesSnapshot(t *testing.T) {
596+ fx := faultfs.New()
597+ put(fx, snapshotName(2), snapshotBytes(t, 2, snapEnt("a", "1", 1), snapEnt("b", "2", 2)))
598+ b := appendLogHeader(nil, 0)
599+ b = appendRecord(b, 1, []op{putOp("a", "1")}, []op{putOp("b", "2")}, []op{putOp("c", "3")}, []op{delOp("a")})
600+ b = appendRecord(b, 5, []op{putOp("d", "5")})
601+ put(fx, logName(0), b)
602+ s, rec := mustRecover(t, fx)
603+ wantState(t, s, map[string]string{"b": "2", "c": "3", "d": "5"})
604+ if got := fmt.Sprint(s.seqs); got != "[3 4 5]" {
605+ t.Fatalf("applied seqs %s, want [3 4 5]", got)
606+ }
607+ if rec.snapSeq != 2 || rec.lastSeq != 5 {
608+ t.Fatalf("rec = %+v", rec)
609+ }
610+
611+ // A record that ends exactly at the snapshot seq adds nothing.
612+ fx = faultfs.New()
613+ put(fx, snapshotName(2), snapshotBytes(t, 2, snapEnt("a", "1", 1), snapEnt("b", "2", 2)))
614+ put(fx, logName(0), cat(appendLogHeader(nil, 0), appendRecord(nil, 1, []op{putOp("a", "1")}, []op{putOp("b", "2")})))
615+ s, rec = mustRecover(t, fx)
616+ if len(s.seqs) != 0 || rec.lastSeq != 2 {
617+ t.Fatalf("applied %v, rec = %+v", s.seqs, rec)
618+ }
619+}
620+
621+func TestRecoverTornGroupRecordFollowedByValidRecord(t *testing.T) {
622+ r1 := appendRecord(nil, 1, []op{putOp("a", "1")}, []op{putOp("b", "2")})
623+ r2 := appendRecord(nil, 3, []op{putOp("c", "3")}, []op{putOp("d", "4")})
624+ r3 := appendRecord(nil, 5, []op{putOp("e", "5")})
625+ damaged := slices.Clone(r2)
626+ damaged[len(damaged)/2] ^= 0xff
627+ fx := faultfs.New()
628+ data := cat(appendLogHeader(nil, 0), r1, damaged, r3)
629+ put(fx, logName(0), data)
630+ if _, _, err := recoverState(t, fx, noLog); !errors.Is(err, ErrCorrupt) {
631+ t.Fatalf("err = %v, want ErrCorrupt", err)
632+ }
633+ if got, _ := fx.ReadFile(filepath.Join(testDir, logName(0))); !bytes.Equal(got, data) {
634+ t.Fatal("corrupt log was modified")
635+ }
636+
637+ // Without a readable record after it, the same damage is a torn tail
638+ // that drops the whole group record, and nothing of it is applied.
639+ fx = faultfs.New()
640+ put(fx, logName(0), cat(appendLogHeader(nil, 0), r1, damaged))
641+ s, rec := mustRecover(t, fx)
642+ wantState(t, s, map[string]string{"a": "1", "b": "2"})
643+ if rec.lastSeq != 2 {
644+ t.Fatalf("rec = %+v", rec)
645+ }
646+}
647+
648+// TestRecoverDamagedLenIsTornTail checks that a damaged len field of the last
649+// record is handled as a torn tail: the record is dropped.
650+func TestRecoverDamagedLenIsTornTail(t *testing.T) {
651+ good := logBytes(0, []op{putOp("a", "1")})
652+ r2 := appendRecord(nil, 2, []op{putOp("b", "2")})
653+ for name, patch := range map[string]func(b []byte){
654+ "len beyond EOF": func(b []byte) { b[1] = 0x7f },
655+ "len above max": func(b []byte) { b[3] = 0x7f },
656+ "len below min": func(b []byte) { clear(b[:4]) },
657+ "len off by some": func(b []byte) { b[0] += 3 },
658+ } {
659+ t.Run(name, func(t *testing.T) {
660+ bad := slices.Clone(r2)
661+ patch(bad)
662+ fx := faultfs.New()
663+ put(fx, logName(0), cat(good, bad))
664+ s, rec := mustRecover(t, fx)
665+ wantState(t, s, map[string]string{"a": "1"})
666+ if rec.lastSeq != 1 || rec.logSize != int64(len(good)) {
667+ t.Fatalf("rec = %+v", rec)
668+ }
669+ })
670+ }
671+}
M snapshot.go +9 -5
@@ -2,7 +2,10 @@
22
33 package kv
44
5-import "iter"
5+import (
6+ "context"
7+ "iter"
8+)
69
710 // Item is the value stored under a key.
811 type Item struct {
@@ -29,18 +32,19 @@ func (s *Snapshot) Seq() uint64 { return s.seq }
2932 // Len returns the number of keys.
3033 func (s *Snapshot) Len() int { return s.t.len() }
3134
32-// Get returns the item stored under key.
33-func (s *Snapshot) Get(key []byte) (Item, bool) { return s.t.get(key) }
35+// Get returns the item stored under key. Reads never block or log: ctx is
36+// not checked.
37+func (s *Snapshot) Get(_ context.Context, key []byte) (Item, bool) { return s.t.get(key) }
3438
3539 // Range iterates over the keys in [start, end) in ascending order. A nil end
3640 // (or an empty start) is unbounded. Breaking out of the loop stops the walk.
37-func (s *Snapshot) Range(start, end []byte) iter.Seq2[[]byte, Item] {
41+func (s *Snapshot) Range(_ context.Context, start, end []byte) iter.Seq2[[]byte, Item] {
3842 return s.t.ascend(start, end)
3943 }
4044
4145 // Prefix iterates over the keys that begin with p in ascending order. An
4246 // empty p matches every key.
43-func (s *Snapshot) Prefix(p []byte) iter.Seq2[[]byte, Item] {
47+func (s *Snapshot) Prefix(_ context.Context, p []byte) iter.Seq2[[]byte, Item] {
4448 return s.t.ascend(p, prefixEnd(p))
4549 }
4650
@@ -2,7 +2,10 @@
2 2
3 package kv3 package kv
4 4
5-import "iter"5+import (
6+ "context"
7+ "iter"
8+)
6 9
7 // Item is the value stored under a key.10 // Item is the value stored under a key.
8 type Item struct {11 type Item struct {
@@ -29,18 +32,19 @@ func (s *Snapshot) Seq() uint64 { return s.seq }
29 // Len returns the number of keys.32 // Len returns the number of keys.
30 func (s *Snapshot) Len() int { return s.t.len() }33 func (s *Snapshot) Len() int { return s.t.len() }
31 34
32-// Get returns the item stored under key.35+// Get returns the item stored under key. Reads never block or log: ctx is
33-func (s *Snapshot) Get(key []byte) (Item, bool) { return s.t.get(key) }36+// not checked.
37+func (s *Snapshot) Get(_ context.Context, key []byte) (Item, bool) { return s.t.get(key) }
34 38
35 // Range iterates over the keys in [start, end) in ascending order. A nil end39 // Range iterates over the keys in [start, end) in ascending order. A nil end
36 // (or an empty start) is unbounded. Breaking out of the loop stops the walk.40 // (or an empty start) is unbounded. Breaking out of the loop stops the walk.
37-func (s *Snapshot) Range(start, end []byte) iter.Seq2[[]byte, Item] {41+func (s *Snapshot) Range(_ context.Context, start, end []byte) iter.Seq2[[]byte, Item] {
38 return s.t.ascend(start, end)42 return s.t.ascend(start, end)
39 }43 }
40 44
41 // Prefix iterates over the keys that begin with p in ascending order. An45 // Prefix iterates over the keys that begin with p in ascending order. An
42 // empty p matches every key.46 // empty p matches every key.
43-func (s *Snapshot) Prefix(p []byte) iter.Seq2[[]byte, Item] {47+func (s *Snapshot) Prefix(_ context.Context, p []byte) iter.Seq2[[]byte, Item] {
44 return s.t.ascend(p, prefixEnd(p))48 return s.t.ascend(p, prefixEnd(p))
45 }49 }
46 50
M tree_test.go +13 -13
@@ -135,18 +135,18 @@ func checkAgainst(t *testing.T, rng *rand.Rand, tr tree, m model, genKey func()
135135 }
136136 }
137137 }
138- verify("full scan", collect(s.Range(nil, nil)), nil, nil)
138+ verify("full scan", collect(s.Range(bg, nil, nil)), nil, nil)
139139
140140 for range 20 {
141141 k := genKey()
142- got, ok := s.Get(k)
142+ got, ok := s.Get(bg, k)
143143 want, wok := m[string(k)]
144144 if ok != wok || (ok && !equalItem(got, want)) {
145145 t.Fatalf("Get(%x) = %v,%v want %v,%v", k, got, ok, want, wok)
146146 }
147147 }
148148 for k, want := range m {
149- if got, ok := s.Get([]byte(k)); !ok || !equalItem(got, want) {
149+ if got, ok := s.Get(bg, []byte(k)); !ok || !equalItem(got, want) {
150150 t.Fatalf("Get(%x) = %v,%v want %v", k, got, ok, want)
151151 }
152152 break // one arbitrary present key is enough per check
@@ -156,9 +156,9 @@ func checkAgainst(t *testing.T, rng *rand.Rand, tr tree, m model, genKey func()
156156 if bytes.Compare(a, b) > 0 {
157157 a, b = b, a
158158 }
159- verify("range", collect(s.Range(a, b)), a, b)
160- verify("range open end", collect(s.Range(a, nil)), a, nil)
161- verify("range open start", collect(s.Range(nil, b)), nil, b)
159+ verify("range", collect(s.Range(bg, a, b)), a, b)
160+ verify("range open end", collect(s.Range(bg, a, nil)), a, nil)
161+ verify("range open start", collect(s.Range(bg, nil, b)), nil, b)
162162 }
163163 for range 10 {
164164 p := genKey()
@@ -169,7 +169,7 @@ func checkAgainst(t *testing.T, rng *rand.Rand, tr tree, m model, genKey func()
169169 want = append(want, k)
170170 }
171171 }
172- got := collect(s.Prefix(p))
172+ got := collect(s.Prefix(bg, p))
173173 if len(got) != len(want) {
174174 t.Fatalf("prefix %x: got %d items, want %d", p, len(got), len(want))
175175 }
@@ -181,7 +181,7 @@ func checkAgainst(t *testing.T, rng *rand.Rand, tr tree, m model, genKey func()
181181 }
182182 // Early break stops the walk.
183183 seen := 0
184- for range s.Range(nil, nil) {
184+ for range s.Range(bg, nil, nil) {
185185 seen++
186186 if seen == 3 {
187187 break
@@ -355,10 +355,10 @@ func TestPrefixAllFF(t *testing.T) {
355355 tr = tr.put(k, nil, uint64(i+1))
356356 }
357357 s := newSnapshot(tr, 4)
358- if got := collect(s.Prefix([]byte{0xFF})); len(got) != 3 {
358+ if got := collect(s.Prefix(bg, []byte{0xFF})); len(got) != 3 {
359359 t.Fatalf("prefix FF: %d items, want 3", len(got))
360360 }
361- if got := collect(s.Prefix(nil)); len(got) != 4 {
361+ if got := collect(s.Prefix(bg, nil)); len(got) != 4 {
362362 t.Fatalf("empty prefix: %d items, want 4", len(got))
363363 }
364364 }
@@ -378,7 +378,7 @@ func BenchmarkSnapshotGet(b *testing.B) {
378378 b.ReportAllocs()
379379 b.ResetTimer()
380380 for i := 0; b.Loop(); i++ {
381- if _, ok := s.Get(keys[(i*7919)%len(keys)]); !ok {
381+ if _, ok := s.Get(bg, keys[(i*7919)%len(keys)]); !ok {
382382 b.Fatal("missing key")
383383 }
384384 }
@@ -389,7 +389,7 @@ func BenchmarkRange(b *testing.B) {
389389 b.ReportAllocs()
390390 for b.Loop() {
391391 n := 0
392- for range s.Range(nil, nil) {
392+ for range s.Range(bg, nil, nil) {
393393 n++
394394 }
395395 if n != 100_000 {
@@ -470,7 +470,7 @@ func TestConcurrentReaders(t *testing.T) {
470470 s := cur.Load()
471471 n := 0
472472 var prev []byte
473- for k, it := range s.Range(nil, nil) {
473+ for k, it := range s.Range(bg, nil, nil) {
474474 if prev != nil && bytes.Compare(prev, k) >= 0 {
475475 t.Errorf("seq %d: keys out of order", s.Seq())
476476 return
@@ -135,18 +135,18 @@ func checkAgainst(t *testing.T, rng *rand.Rand, tr tree, m model, genKey func()
135 }135 }
136 }136 }
137 }137 }
138- verify("full scan", collect(s.Range(nil, nil)), nil, nil)138+ verify("full scan", collect(s.Range(bg, nil, nil)), nil, nil)
139 139
140 for range 20 {140 for range 20 {
141 k := genKey()141 k := genKey()
142- got, ok := s.Get(k)142+ got, ok := s.Get(bg, k)
143 want, wok := m[string(k)]143 want, wok := m[string(k)]
144 if ok != wok || (ok && !equalItem(got, want)) {144 if ok != wok || (ok && !equalItem(got, want)) {
145 t.Fatalf("Get(%x) = %v,%v want %v,%v", k, got, ok, want, wok)145 t.Fatalf("Get(%x) = %v,%v want %v,%v", k, got, ok, want, wok)
146 }146 }
147 }147 }
148 for k, want := range m {148 for k, want := range m {
149- if got, ok := s.Get([]byte(k)); !ok || !equalItem(got, want) {149+ if got, ok := s.Get(bg, []byte(k)); !ok || !equalItem(got, want) {
150 t.Fatalf("Get(%x) = %v,%v want %v", k, got, ok, want)150 t.Fatalf("Get(%x) = %v,%v want %v", k, got, ok, want)
151 }151 }
152 break // one arbitrary present key is enough per check152 break // one arbitrary present key is enough per check
@@ -156,9 +156,9 @@ func checkAgainst(t *testing.T, rng *rand.Rand, tr tree, m model, genKey func()
156 if bytes.Compare(a, b) > 0 {156 if bytes.Compare(a, b) > 0 {
157 a, b = b, a157 a, b = b, a
158 }158 }
159- verify("range", collect(s.Range(a, b)), a, b)159+ verify("range", collect(s.Range(bg, a, b)), a, b)
160- verify("range open end", collect(s.Range(a, nil)), a, nil)160+ verify("range open end", collect(s.Range(bg, a, nil)), a, nil)
161- verify("range open start", collect(s.Range(nil, b)), nil, b)161+ verify("range open start", collect(s.Range(bg, nil, b)), nil, b)
162 }162 }
163 for range 10 {163 for range 10 {
164 p := genKey()164 p := genKey()
@@ -169,7 +169,7 @@ func checkAgainst(t *testing.T, rng *rand.Rand, tr tree, m model, genKey func()
169 want = append(want, k)169 want = append(want, k)
170 }170 }
171 }171 }
172- got := collect(s.Prefix(p))172+ got := collect(s.Prefix(bg, p))
173 if len(got) != len(want) {173 if len(got) != len(want) {
174 t.Fatalf("prefix %x: got %d items, want %d", p, len(got), len(want))174 t.Fatalf("prefix %x: got %d items, want %d", p, len(got), len(want))
175 }175 }
@@ -181,7 +181,7 @@ func checkAgainst(t *testing.T, rng *rand.Rand, tr tree, m model, genKey func()
181 }181 }
182 // Early break stops the walk.182 // Early break stops the walk.
183 seen := 0183 seen := 0
184- for range s.Range(nil, nil) {184+ for range s.Range(bg, nil, nil) {
185 seen++185 seen++
186 if seen == 3 {186 if seen == 3 {
187 break187 break
@@ -355,10 +355,10 @@ func TestPrefixAllFF(t *testing.T) {
355 tr = tr.put(k, nil, uint64(i+1))355 tr = tr.put(k, nil, uint64(i+1))
356 }356 }
357 s := newSnapshot(tr, 4)357 s := newSnapshot(tr, 4)
358- if got := collect(s.Prefix([]byte{0xFF})); len(got) != 3 {358+ if got := collect(s.Prefix(bg, []byte{0xFF})); len(got) != 3 {
359 t.Fatalf("prefix FF: %d items, want 3", len(got))359 t.Fatalf("prefix FF: %d items, want 3", len(got))
360 }360 }
361- if got := collect(s.Prefix(nil)); len(got) != 4 {361+ if got := collect(s.Prefix(bg, nil)); len(got) != 4 {
362 t.Fatalf("empty prefix: %d items, want 4", len(got))362 t.Fatalf("empty prefix: %d items, want 4", len(got))
363 }363 }
364 }364 }
@@ -378,7 +378,7 @@ func BenchmarkSnapshotGet(b *testing.B) {
378 b.ReportAllocs()378 b.ReportAllocs()
379 b.ResetTimer()379 b.ResetTimer()
380 for i := 0; b.Loop(); i++ {380 for i := 0; b.Loop(); i++ {
381- if _, ok := s.Get(keys[(i*7919)%len(keys)]); !ok {381+ if _, ok := s.Get(bg, keys[(i*7919)%len(keys)]); !ok {
382 b.Fatal("missing key")382 b.Fatal("missing key")
383 }383 }
384 }384 }
@@ -389,7 +389,7 @@ func BenchmarkRange(b *testing.B) {
389 b.ReportAllocs()389 b.ReportAllocs()
390 for b.Loop() {390 for b.Loop() {
391 n := 0391 n := 0
392- for range s.Range(nil, nil) {392+ for range s.Range(bg, nil, nil) {
393 n++393 n++
394 }394 }
395 if n != 100_000 {395 if n != 100_000 {
@@ -470,7 +470,7 @@ func TestConcurrentReaders(t *testing.T) {
470 s := cur.Load()470 s := cur.Load()
471 n := 0471 n := 0
472 var prev []byte472 var prev []byte
473- for k, it := range s.Range(nil, nil) {473+ for k, it := range s.Range(bg, nil, nil) {
474 if prev != nil && bytes.Compare(prev, k) >= 0 {474 if prev != nil && bytes.Compare(prev, k) >= 0 {
475 t.Errorf("seq %d: keys out of order", s.Seq())475 t.Errorf("seq %d: keys out of order", s.Seq())
476 return476 return
M writer.go +29 -15
@@ -85,7 +85,7 @@ func (db *DB) writer() {
8585 // under maxGroupBytes.
8686 func (db *DB) collect(first *request) (overflow *request) {
8787 db.group = append(db.group[:0], first)
88- size := first.b.payload()
88+ size := recordHead + first.b.encodedSize()
8989 var expired <-chan time.Time
9090 if w := db.opts.GroupWindow; w > 0 {
9191 t := time.NewTimer(w)
@@ -109,7 +109,7 @@ func (db *DB) collect(first *request) (overflow *request) {
109109 return nil
110110 }
111111 }
112- if p := r.b.payload(); size+p <= maxGroupBytes {
112+ if p := r.b.encodedSize(); size+p <= maxGroupBytes {
113113 db.group = append(db.group, r)
114114 size += p
115115 } else {
@@ -128,6 +128,8 @@ func (db *DB) commit() bool {
128128 seq := cur.seq
129129 buf := db.buf[:0]
130130 results := make([]result, len(db.group))
131+ var batches int
132+ var firstSeq uint64
131133
132134 for i, r := range db.group {
133135 b := r.b
@@ -147,7 +149,12 @@ func (db *DB) commit() bool {
147149 bld.delete(o.key)
148150 }
149151 }
150- buf = appendRecord(buf, seq, b.ops)
152+ if batches == 0 {
153+ firstSeq = seq
154+ buf = beginRecord(buf, seq)
155+ }
156+ buf = appendBatch(buf, b.ops)
157+ batches++
151158 results[i] = result{seq: seq}
152159 }
153160 db.buf = buf[:0]
@@ -155,8 +162,10 @@ func (db *DB) commit() bool {
155162 db.buf = nil // do not pin the memory of one huge batch
156163 }
157164
158- if len(buf) > 0 {
159- if err := db.persist(buf); err != nil {
165+ if batches > 0 {
166+ finishRecord(buf, batches)
167+ syncTime, err := db.persist(buf)
168+ if err != nil {
160169 err = db.fail(err)
161170 for _, r := range db.group {
162171 r.reply <- result{err: err}
@@ -165,6 +174,8 @@ func (db *DB) commit() bool {
165174 }
166175 db.logSize += int64(len(buf))
167176 db.snap.Store(newSnapshot(bld.freeze(), seq))
177+ db.log.Debug().Uint64("seq", firstSeq).Int("batches", batches).Int("bytes", len(buf)).
178+ Dur("sync", syncTime).Msg("group committed")
168179 }
169180 for i, r := range db.group {
170181 r.reply <- results[i]
@@ -172,21 +183,24 @@ func (db *DB) commit() bool {
172183 return true
173184 }
174185
175-// persist appends buf to the log and syncs it (unless NoSync).
176-func (db *DB) persist(buf []byte) error {
177- n, err := db.log.Write(buf)
186+// persist appends buf to the log and syncs it (unless NoSync). It returns
187+// the time spent in the sync.
188+func (db *DB) persist(buf []byte) (time.Duration, error) {
189+ n, err := db.file.Write(buf)
178190 if err == nil && n < len(buf) {
179191 err = io.ErrShortWrite
180192 }
181193 if err != nil {
182- return fmt.Errorf("write log: %w", err)
194+ return 0, fmt.Errorf("write log: %w", err)
183195 }
184- if !db.opts.NoSync {
185- if err := db.log.Sync(); err != nil {
186- return fmt.Errorf("sync log: %w", err)
187- }
196+ if db.opts.NoSync {
197+ return 0, nil
198+ }
199+ start := time.Now()
200+ if err := db.file.Sync(); err != nil {
201+ return 0, fmt.Errorf("sync log: %w", err)
188202 }
189- return nil
203+ return time.Since(start), nil
190204 }
191205
192206 // fail puts the database in the failed state, keeping the first cause, and
@@ -194,7 +208,7 @@ func (db *DB) persist(buf []byte) error {
194208 func (db *DB) fail(cause error) error {
195209 db.failOnce.Do(func() {
196210 db.failErr = fmt.Errorf("%w: %w", ErrFailed, cause)
197- db.opts.Logger.Error("kv: database failed", "err", cause)
211+ db.log.Error().Err(cause).Msg("database failed")
198212 close(db.failed)
199213 })
200214 return db.failErr
@@ -85,7 +85,7 @@ func (db *DB) writer() {
85 // under maxGroupBytes.85 // under maxGroupBytes.
86 func (db *DB) collect(first *request) (overflow *request) {86 func (db *DB) collect(first *request) (overflow *request) {
87 db.group = append(db.group[:0], first)87 db.group = append(db.group[:0], first)
88- size := first.b.payload()88+ size := recordHead + first.b.encodedSize()
89 var expired <-chan time.Time89 var expired <-chan time.Time
90 if w := db.opts.GroupWindow; w > 0 {90 if w := db.opts.GroupWindow; w > 0 {
91 t := time.NewTimer(w)91 t := time.NewTimer(w)
@@ -109,7 +109,7 @@ func (db *DB) collect(first *request) (overflow *request) {
109 return nil109 return nil
110 }110 }
111 }111 }
112- if p := r.b.payload(); size+p <= maxGroupBytes {112+ if p := r.b.encodedSize(); size+p <= maxGroupBytes {
113 db.group = append(db.group, r)113 db.group = append(db.group, r)
114 size += p114 size += p
115 } else {115 } else {
@@ -128,6 +128,8 @@ func (db *DB) commit() bool {
128 seq := cur.seq128 seq := cur.seq
129 buf := db.buf[:0]129 buf := db.buf[:0]
130 results := make([]result, len(db.group))130 results := make([]result, len(db.group))
131+ var batches int
132+ var firstSeq uint64
131 133
132 for i, r := range db.group {134 for i, r := range db.group {
133 b := r.b135 b := r.b
@@ -147,7 +149,12 @@ func (db *DB) commit() bool {
147 bld.delete(o.key)149 bld.delete(o.key)
148 }150 }
149 }151 }
150- buf = appendRecord(buf, seq, b.ops)152+ if batches == 0 {
153+ firstSeq = seq
154+ buf = beginRecord(buf, seq)
155+ }
156+ buf = appendBatch(buf, b.ops)
157+ batches++
151 results[i] = result{seq: seq}158 results[i] = result{seq: seq}
152 }159 }
153 db.buf = buf[:0]160 db.buf = buf[:0]
@@ -155,8 +162,10 @@ func (db *DB) commit() bool {
155 db.buf = nil // do not pin the memory of one huge batch162 db.buf = nil // do not pin the memory of one huge batch
156 }163 }
157 164
158- if len(buf) > 0 {165+ if batches > 0 {
159- if err := db.persist(buf); err != nil {166+ finishRecord(buf, batches)
167+ syncTime, err := db.persist(buf)
168+ if err != nil {
160 err = db.fail(err)169 err = db.fail(err)
161 for _, r := range db.group {170 for _, r := range db.group {
162 r.reply <- result{err: err}171 r.reply <- result{err: err}
@@ -165,6 +174,8 @@ func (db *DB) commit() bool {
165 }174 }
166 db.logSize += int64(len(buf))175 db.logSize += int64(len(buf))
167 db.snap.Store(newSnapshot(bld.freeze(), seq))176 db.snap.Store(newSnapshot(bld.freeze(), seq))
177+ db.log.Debug().Uint64("seq", firstSeq).Int("batches", batches).Int("bytes", len(buf)).
178+ Dur("sync", syncTime).Msg("group committed")
168 }179 }
169 for i, r := range db.group {180 for i, r := range db.group {
170 r.reply <- results[i]181 r.reply <- results[i]
@@ -172,21 +183,24 @@ func (db *DB) commit() bool {
172 return true183 return true
173 }184 }
174 185
175-// persist appends buf to the log and syncs it (unless NoSync).186+// persist appends buf to the log and syncs it (unless NoSync). It returns
176-func (db *DB) persist(buf []byte) error {187+// the time spent in the sync.
177- n, err := db.log.Write(buf)188+func (db *DB) persist(buf []byte) (time.Duration, error) {
189+ n, err := db.file.Write(buf)
178 if err == nil && n < len(buf) {190 if err == nil && n < len(buf) {
179 err = io.ErrShortWrite191 err = io.ErrShortWrite
180 }192 }
181 if err != nil {193 if err != nil {
182- return fmt.Errorf("write log: %w", err)194+ return 0, fmt.Errorf("write log: %w", err)
183 }195 }
184- if !db.opts.NoSync {196+ if db.opts.NoSync {
185- if err := db.log.Sync(); err != nil {197+ return 0, nil
186- return fmt.Errorf("sync log: %w", err)198+ }
187- }199+ start := time.Now()
200+ if err := db.file.Sync(); err != nil {
201+ return 0, fmt.Errorf("sync log: %w", err)
188 }202 }
189- return nil203+ return time.Since(start), nil
190 }204 }
191 205
192 // fail puts the database in the failed state, keeping the first cause, and206 // fail puts the database in the failed state, keeping the first cause, and
@@ -194,7 +208,7 @@ func (db *DB) persist(buf []byte) error {
194 func (db *DB) fail(cause error) error {208 func (db *DB) fail(cause error) error {
195 db.failOnce.Do(func() {209 db.failOnce.Do(func() {
196 db.failErr = fmt.Errorf("%w: %w", ErrFailed, cause)210 db.failErr = fmt.Errorf("%w: %w", ErrFailed, cause)
197- db.opts.Logger.Error("kv: database failed", "err", cause)211+ db.log.Error().Err(cause).Msg("database failed")
198 close(db.failed)212 close(db.failed)
199 })213 })
200 return db.failErr214 return db.failErr

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 1

Tags 2

↑↓ moveenter openesc close