detached 4776aab

refactor(kv): replace the WAL with whole-file atomic rewrite

commit
4776aab499af8e79d923d4a85d1170b5d660840a
author
brice · · 2026-07-26 12:29 UTC
parent
97f1e81
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +400 -2341
D internal/adapters/recoveryfs/atomic.go +0 -71
@@ -1,71 +0,0 @@
1-package recoveryfs
2-
3-import (
4- "errors"
5- "os"
6- "path/filepath"
7-
8- "github.com/bnema/vev/pkg/safedir"
9-)
10-
11-type atomicWriter struct {
12- hooks journalHooks
13-}
14-
15-type journalHooks struct {
16- syncFile func(string) error
17- rename func(string, string) error
18- syncDirectory func(string) error
19-}
20-
21-func (j *atomicWriter) atomicWrite(path string, data []byte) (retErr error) {
22- dir := filepath.Dir(path)
23- if err := safedir.EnsurePrivate(dir); err != nil {
24- return err
25- }
26- file, err := os.CreateTemp(dir, ".intent-*")
27- if err != nil {
28- return err
29- }
30- tmp := file.Name()
31- defer func() {
32- if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
33- retErr = errors.Join(retErr, err)
34- }
35- }()
36- if err := file.Chmod(0o600); err != nil {
37- return errors.Join(err, file.Close())
38- }
39- if _, err := file.Write(data); err != nil {
40- return errors.Join(err, file.Close())
41- }
42- if j.hooks.syncFile != nil {
43- if err := j.hooks.syncFile(tmp); err != nil {
44- return errors.Join(err, file.Close())
45- }
46- } else if err := file.Sync(); err != nil {
47- return errors.Join(err, file.Close())
48- }
49- if err := file.Close(); err != nil {
50- return err
51- }
52- if j.hooks.rename != nil {
53- if err := j.hooks.rename(tmp, path); err != nil {
54- return err
55- }
56- } else if err := os.Rename(tmp, path); err != nil {
57- return err
58- }
59- return j.syncDir(dir)
60-}
61-
62-func (j *atomicWriter) syncDir(dir string) error {
63- if j.hooks.syncDirectory != nil {
64- return j.hooks.syncDirectory(dir)
65- }
66- file, err := os.Open(dir)
67- if err != nil {
68- return err
69- }
70- return errors.Join(file.Sync(), file.Close())
71-}
@@ -1,71 +0,0 @@
1-package recoveryfs
2-
3-import (
4- "errors"
5- "os"
6- "path/filepath"
7-
8- "github.com/bnema/vev/pkg/safedir"
9-)
10-
11-type atomicWriter struct {
12- hooks journalHooks
13-}
14-
15-type journalHooks struct {
16- syncFile func(string) error
17- rename func(string, string) error
18- syncDirectory func(string) error
19-}
20-
21-func (j *atomicWriter) atomicWrite(path string, data []byte) (retErr error) {
22- dir := filepath.Dir(path)
23- if err := safedir.EnsurePrivate(dir); err != nil {
24- return err
25- }
26- file, err := os.CreateTemp(dir, ".intent-*")
27- if err != nil {
28- return err
29- }
30- tmp := file.Name()
31- defer func() {
32- if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
33- retErr = errors.Join(retErr, err)
34- }
35- }()
36- if err := file.Chmod(0o600); err != nil {
37- return errors.Join(err, file.Close())
38- }
39- if _, err := file.Write(data); err != nil {
40- return errors.Join(err, file.Close())
41- }
42- if j.hooks.syncFile != nil {
43- if err := j.hooks.syncFile(tmp); err != nil {
44- return errors.Join(err, file.Close())
45- }
46- } else if err := file.Sync(); err != nil {
47- return errors.Join(err, file.Close())
48- }
49- if err := file.Close(); err != nil {
50- return err
51- }
52- if j.hooks.rename != nil {
53- if err := j.hooks.rename(tmp, path); err != nil {
54- return err
55- }
56- } else if err := os.Rename(tmp, path); err != nil {
57- return err
58- }
59- return j.syncDir(dir)
60-}
61-
62-func (j *atomicWriter) syncDir(dir string) error {
63- if j.hooks.syncDirectory != nil {
64- return j.hooks.syncDirectory(dir)
65- }
66- file, err := os.Open(dir)
67- if err != nil {
68- return err
69- }
70- return errors.Join(file.Sync(), file.Close())
71-}
M internal/app/offline_kill_retry_test.go +2 -2
@@ -46,11 +46,11 @@ func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
4646 }{
4747 {name: "normal"},
4848 {
49- name: ".prev crash hook",
49+ name: "stray tmp is ignored",
5050 crashHook: func(t *testing.T, stateDir string) {
5151 t.Helper()
5252 storePath := persist.StorePath(stateDir)
53- require.NoError(t, os.Rename(storePath, storePath+".prev"))
53+ require.NoError(t, os.WriteFile(storePath+".tmp", []byte("partial rewrite"), 0o600))
5454 },
5555 },
5656 }
@@ -46,11 +46,11 @@ func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
46 }{46 }{
47 {name: "normal"},47 {name: "normal"},
48 {48 {
49- name: ".prev crash hook",49+ name: "stray tmp is ignored",
50 crashHook: func(t *testing.T, stateDir string) {50 crashHook: func(t *testing.T, stateDir string) {
51 t.Helper()51 t.Helper()
52 storePath := persist.StorePath(stateDir)52 storePath := persist.StorePath(stateDir)
53- require.NoError(t, os.Rename(storePath, storePath+".prev"))53+ require.NoError(t, os.WriteFile(storePath+".tmp", []byte("partial rewrite"), 0o600))
54 },54 },
55 },55 },
56 }56 }
M internal/app/run.go +0 -1
@@ -501,7 +501,6 @@ func runDaemon() (retErr error) {
501501
502502 func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) {
503503 log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode)
504- log.Info("catalogue_compaction_recovery_complete", "recovery", recoveryMode)
505504 }
506505
507506 func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {
@@ -501,7 +501,6 @@ func runDaemon() (retErr error) {
501 501
502 func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) {502 func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) {
503 log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode)503 log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode)
504- log.Info("catalogue_compaction_recovery_complete", "recovery", recoveryMode)
505 }504 }
506 505
507 func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {506 func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {
M internal/app/run_test.go +1 -1
@@ -88,7 +88,7 @@ func TestRecoveryObservability(t *testing.T) {
8888 }, deps))
8989
9090 entries := decodeJSONLogs(t, logBuffer.Bytes())
91- for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "catalogue_compaction_recovery_complete", "daemon_startup_complete", "lifecycle_owner_released"} {
91+ for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "daemon_startup_complete", "lifecycle_owner_released"} {
9292 require.Contains(t, eventNames(entries), name)
9393 }
9494 startup := requireEvent(t, entries, "daemon_startup_complete")
@@ -88,7 +88,7 @@ func TestRecoveryObservability(t *testing.T) {
88 }, deps))88 }, deps))
89 89
90 entries := decodeJSONLogs(t, logBuffer.Bytes())90 entries := decodeJSONLogs(t, logBuffer.Bytes())
91- for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "catalogue_compaction_recovery_complete", "daemon_startup_complete", "lifecycle_owner_released"} {91+ for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "daemon_startup_complete", "lifecycle_owner_released"} {
92 require.Contains(t, eventNames(entries), name)92 require.Contains(t, eventNames(entries), name)
93 }93 }
94 startup := requireEvent(t, entries, "daemon_startup_complete")94 startup := requireEvent(t, entries, "daemon_startup_complete")
M internal/persist/catalogue_test.go +23 -91
@@ -19,7 +19,7 @@ func TestCatalogue(t *testing.T) {
1919 t.Run("metadata-update-preserves-authority", testCatalogueMetadataUpdatePreservesAuthority)
2020 t.Run("read-only-malformed", testCatalogueLoadReadOnlyRejectsMalformedValue)
2121 t.Run("read-only-fresh-install", testCatalogueLoadReadOnlyFreshInstallHasNoSessions)
22- t.Run("read-only-recovers-crash-states", testCatalogueLoadReadOnlyRecoversCrashStates)
22+ t.Run("read-only-ignores-stray-tmp", testCatalogueLoadReadOnlyIgnoresStrayTmp)
2323 }
2424
2525 func testCatalogueRecordRoundTrip(t *testing.T) {
@@ -87,14 +87,13 @@ func testCatalogueOpenFailsClosed(t *testing.T) {
8787 require.Error(t, err)
8888
8989 require.NoError(t, os.MkdirAll(dir, 0o700))
90- require.NoError(t, os.WriteFile(StorePath(dir), rawCorruptCatalogueWAL(), 0o600))
90+ raw := []byte("corrupt catalogue")
91+ require.NoError(t, os.WriteFile(StorePath(dir), raw, 0o600))
9192 _, _, err = openCurrentCatalogue(dir, false)
92- require.ErrorIs(t, err, kv.ErrCorruptWAL)
93-
94- require.NoError(t, os.Remove(StorePath(dir)))
95- require.NoError(t, os.WriteFile(StorePath(dir)+".prev", rawCorruptCatalogueWAL(), 0o600))
96- _, _, err = openCurrentCatalogue(dir, false)
97- require.ErrorIs(t, err, kv.ErrCorruptWAL)
93+ require.ErrorIs(t, err, kv.ErrCorrupt)
94+ after, readErr := os.ReadFile(StorePath(dir))
95+ require.NoError(t, readErr)
96+ require.Equal(t, raw, after)
9897 }
9998
10099 func testCatalogueApplyRenameReplace(t *testing.T) {
@@ -161,11 +160,6 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
161160 require.Equal(t, expected, unchanged)
162161 }
163162
164-func rawCorruptCatalogueWAL() []byte {
165- // A complete WAL record with an impossible CRC.
166- return []byte{0, 0, 0, 3, 0, 0, 0, 0, 0xff, 0, 0}
167-}
168-
169163 func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {
170164 dir := privateDir(t)
171165 store, err := kv.Open(filepath.Join(dir, filename))
@@ -177,9 +171,8 @@ func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {
177171 }
178172
179173 // testCatalogueLoadReadOnlyFreshInstallHasNoSessions covers `vev ls` on a
180-// machine with no daemon and no catalogue: none of sessions.kv, .next, or
181-// .prev exist. This must yield an empty catalogue, not an error (regression:
182-// a bare os.Stat propagated ErrNotExist and made `vev ls` exit 1).
174+// machine with no daemon and no catalogue. This must yield an empty catalogue,
175+// not an error.
183176 func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {
184177 dir := privateDir(t)
185178 records, err := LoadCatalogueReadOnly(dir)
@@ -187,80 +180,19 @@ func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {
187180 require.Empty(t, records)
188181 }
189182
190-// testCatalogueLoadReadOnlyRecoversCrashStates covers spec acceptance
191-// criterion 3 ("every valid catalogue record appears in vev list") for a
192-// listing taken while no daemon is running: whenever any compaction
193-// candidate exists, LoadCatalogueReadOnly must apply the same fixed-path
194-// recovery the daemon uses on startup rather than failing the read.
195-func testCatalogueLoadReadOnlyRecoversCrashStates(t *testing.T) {
196- // Built through an unrelated scratch path and copied into place, rather
197- // than kv.Open'd directly at the target path: kv.Open recovers its own
198- // base path on every call, and target paths here deliberately coexist
199- // with sibling .next/.prev candidates that would otherwise be recovered
200- // away before the test gets to arrange its crash state.
201- writeCandidate := func(t *testing.T, path string, records ...domain.CatalogueRecord) {
202- t.Helper()
203- scratch := filepath.Join(t.TempDir(), "scratch", "store.kv")
204- store, err := kv.Open(scratch)
205- require.NoError(t, err)
206- for _, record := range records {
207- value, err := encodeRecordValue(record)
208- require.NoError(t, err)
209- require.NoError(t, store.Set([]byte(record.Name), value))
210- }
211- require.NoError(t, store.Close())
212- data, err := os.ReadFile(scratch)
213- require.NoError(t, err)
214- require.NoError(t, os.WriteFile(path, data, 0o600))
215- }
216-
217- tests := []struct {
218- name string
219- write func(t *testing.T, dir string, want domain.CatalogueRecord)
220- }{
221- {
222- // Crash between "rename current->.prev" and "rename .next->current":
223- // no current, both .prev (stale) and .next (authoritative) exist.
224- name: "prev-and-next-recovers-from-next",
225- write: func(t *testing.T, dir string, want domain.CatalogueRecord) {
226- stale := want
227- stale.IncarnationID = domain.IncarnationID{9}
228- writeCandidate(t, StorePath(dir)+".prev", stale)
229- writeCandidate(t, StorePath(dir)+".next", want)
230- },
231- },
232- {
233- // Crash before a new installation publishes .next as current.
234- name: "next-only-recovers-from-next",
235- write: func(t *testing.T, dir string, want domain.CatalogueRecord) {
236- writeCandidate(t, StorePath(dir)+".next", want)
237- },
238- },
239- {
240- // Crash leaves only a predecessor behind; no current, no successor.
241- name: "prev-only-recovers-from-prev",
242- write: func(t *testing.T, dir string, want domain.CatalogueRecord) {
243- writeCandidate(t, StorePath(dir)+".prev", want)
244- },
245- },
246- }
247- for _, tt := range tests {
248- t.Run(tt.name, func(t *testing.T) {
249- dir := privateDir(t)
250- require.NoError(t, os.MkdirAll(dir, 0o700))
251- want := validRecord("recovered", 1)
252- tt.write(t, dir, want)
253-
254- records, err := LoadCatalogueReadOnly(dir)
255- require.NoError(t, err)
256- require.ElementsMatch(t, []domain.CatalogueRecord{want}, records)
183+func testCatalogueLoadReadOnlyIgnoresStrayTmp(t *testing.T) {
184+ dir := privateDir(t)
185+ p, _, err := openCurrentCatalogue(dir, true)
186+ require.NoError(t, err)
187+ want := validRecord("work", 1)
188+ require.NoError(t, p.Create(want))
189+ require.NoError(t, p.Close())
190+ require.NoError(t, os.WriteFile(StorePath(dir)+".tmp", []byte("partial rewrite"), 0o600))
257191
258- _, statErr := os.Stat(StorePath(dir))
259- require.NoError(t, statErr, "recovery must leave a valid current catalogue in place")
260- _, nextErr := os.Stat(StorePath(dir) + ".next")
261- require.True(t, os.IsNotExist(nextErr), ".next must not survive recovery")
262- _, prevErr := os.Stat(StorePath(dir) + ".prev")
263- require.True(t, os.IsNotExist(prevErr), ".prev must not survive recovery")
264- })
265- }
192+ records, err := LoadCatalogueReadOnly(dir)
193+ require.NoError(t, err)
194+ require.Equal(t, []domain.CatalogueRecord{want}, records)
195+ tmp, err := os.ReadFile(StorePath(dir) + ".tmp")
196+ require.NoError(t, err)
197+ require.Equal(t, []byte("partial rewrite"), tmp)
266198 }
@@ -19,7 +19,7 @@ func TestCatalogue(t *testing.T) {
19 t.Run("metadata-update-preserves-authority", testCatalogueMetadataUpdatePreservesAuthority)19 t.Run("metadata-update-preserves-authority", testCatalogueMetadataUpdatePreservesAuthority)
20 t.Run("read-only-malformed", testCatalogueLoadReadOnlyRejectsMalformedValue)20 t.Run("read-only-malformed", testCatalogueLoadReadOnlyRejectsMalformedValue)
21 t.Run("read-only-fresh-install", testCatalogueLoadReadOnlyFreshInstallHasNoSessions)21 t.Run("read-only-fresh-install", testCatalogueLoadReadOnlyFreshInstallHasNoSessions)
22- t.Run("read-only-recovers-crash-states", testCatalogueLoadReadOnlyRecoversCrashStates)22+ t.Run("read-only-ignores-stray-tmp", testCatalogueLoadReadOnlyIgnoresStrayTmp)
23 }23 }
24 24
25 func testCatalogueRecordRoundTrip(t *testing.T) {25 func testCatalogueRecordRoundTrip(t *testing.T) {
@@ -87,14 +87,13 @@ func testCatalogueOpenFailsClosed(t *testing.T) {
87 require.Error(t, err)87 require.Error(t, err)
88 88
89 require.NoError(t, os.MkdirAll(dir, 0o700))89 require.NoError(t, os.MkdirAll(dir, 0o700))
90- require.NoError(t, os.WriteFile(StorePath(dir), rawCorruptCatalogueWAL(), 0o600))90+ raw := []byte("corrupt catalogue")
91+ require.NoError(t, os.WriteFile(StorePath(dir), raw, 0o600))
91 _, _, err = openCurrentCatalogue(dir, false)92 _, _, err = openCurrentCatalogue(dir, false)
92- require.ErrorIs(t, err, kv.ErrCorruptWAL)93+ require.ErrorIs(t, err, kv.ErrCorrupt)
93-94+ after, readErr := os.ReadFile(StorePath(dir))
94- require.NoError(t, os.Remove(StorePath(dir)))95+ require.NoError(t, readErr)
95- require.NoError(t, os.WriteFile(StorePath(dir)+".prev", rawCorruptCatalogueWAL(), 0o600))96+ require.Equal(t, raw, after)
96- _, _, err = openCurrentCatalogue(dir, false)
97- require.ErrorIs(t, err, kv.ErrCorruptWAL)
98 }97 }
99 98
100 func testCatalogueApplyRenameReplace(t *testing.T) {99 func testCatalogueApplyRenameReplace(t *testing.T) {
@@ -161,11 +160,6 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
161 require.Equal(t, expected, unchanged)160 require.Equal(t, expected, unchanged)
162 }161 }
163 162
164-func rawCorruptCatalogueWAL() []byte {
165- // A complete WAL record with an impossible CRC.
166- return []byte{0, 0, 0, 3, 0, 0, 0, 0, 0xff, 0, 0}
167-}
168-
169 func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {163 func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {
170 dir := privateDir(t)164 dir := privateDir(t)
171 store, err := kv.Open(filepath.Join(dir, filename))165 store, err := kv.Open(filepath.Join(dir, filename))
@@ -177,9 +171,8 @@ func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {
177 }171 }
178 172
179 // testCatalogueLoadReadOnlyFreshInstallHasNoSessions covers `vev ls` on a173 // testCatalogueLoadReadOnlyFreshInstallHasNoSessions covers `vev ls` on a
180-// machine with no daemon and no catalogue: none of sessions.kv, .next, or174+// machine with no daemon and no catalogue. This must yield an empty catalogue,
181-// .prev exist. This must yield an empty catalogue, not an error (regression:175+// not an error.
182-// a bare os.Stat propagated ErrNotExist and made `vev ls` exit 1).
183 func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {176 func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {
184 dir := privateDir(t)177 dir := privateDir(t)
185 records, err := LoadCatalogueReadOnly(dir)178 records, err := LoadCatalogueReadOnly(dir)
@@ -187,80 +180,19 @@ func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {
187 require.Empty(t, records)180 require.Empty(t, records)
188 }181 }
189 182
190-// testCatalogueLoadReadOnlyRecoversCrashStates covers spec acceptance183+func testCatalogueLoadReadOnlyIgnoresStrayTmp(t *testing.T) {
191-// criterion 3 ("every valid catalogue record appears in vev list") for a184+ dir := privateDir(t)
192-// listing taken while no daemon is running: whenever any compaction185+ p, _, err := openCurrentCatalogue(dir, true)
193-// candidate exists, LoadCatalogueReadOnly must apply the same fixed-path186+ require.NoError(t, err)
194-// recovery the daemon uses on startup rather than failing the read.187+ want := validRecord("work", 1)
195-func testCatalogueLoadReadOnlyRecoversCrashStates(t *testing.T) {188+ require.NoError(t, p.Create(want))
196- // Built through an unrelated scratch path and copied into place, rather189+ require.NoError(t, p.Close())
197- // than kv.Open'd directly at the target path: kv.Open recovers its own190+ require.NoError(t, os.WriteFile(StorePath(dir)+".tmp", []byte("partial rewrite"), 0o600))
198- // base path on every call, and target paths here deliberately coexist
199- // with sibling .next/.prev candidates that would otherwise be recovered
200- // away before the test gets to arrange its crash state.
201- writeCandidate := func(t *testing.T, path string, records ...domain.CatalogueRecord) {
202- t.Helper()
203- scratch := filepath.Join(t.TempDir(), "scratch", "store.kv")
204- store, err := kv.Open(scratch)
205- require.NoError(t, err)
206- for _, record := range records {
207- value, err := encodeRecordValue(record)
208- require.NoError(t, err)
209- require.NoError(t, store.Set([]byte(record.Name), value))
210- }
211- require.NoError(t, store.Close())
212- data, err := os.ReadFile(scratch)
213- require.NoError(t, err)
214- require.NoError(t, os.WriteFile(path, data, 0o600))
215- }
216-
217- tests := []struct {
218- name string
219- write func(t *testing.T, dir string, want domain.CatalogueRecord)
220- }{
221- {
222- // Crash between "rename current->.prev" and "rename .next->current":
223- // no current, both .prev (stale) and .next (authoritative) exist.
224- name: "prev-and-next-recovers-from-next",
225- write: func(t *testing.T, dir string, want domain.CatalogueRecord) {
226- stale := want
227- stale.IncarnationID = domain.IncarnationID{9}
228- writeCandidate(t, StorePath(dir)+".prev", stale)
229- writeCandidate(t, StorePath(dir)+".next", want)
230- },
231- },
232- {
233- // Crash before a new installation publishes .next as current.
234- name: "next-only-recovers-from-next",
235- write: func(t *testing.T, dir string, want domain.CatalogueRecord) {
236- writeCandidate(t, StorePath(dir)+".next", want)
237- },
238- },
239- {
240- // Crash leaves only a predecessor behind; no current, no successor.
241- name: "prev-only-recovers-from-prev",
242- write: func(t *testing.T, dir string, want domain.CatalogueRecord) {
243- writeCandidate(t, StorePath(dir)+".prev", want)
244- },
245- },
246- }
247- for _, tt := range tests {
248- t.Run(tt.name, func(t *testing.T) {
249- dir := privateDir(t)
250- require.NoError(t, os.MkdirAll(dir, 0o700))
251- want := validRecord("recovered", 1)
252- tt.write(t, dir, want)
253-
254- records, err := LoadCatalogueReadOnly(dir)
255- require.NoError(t, err)
256- require.ElementsMatch(t, []domain.CatalogueRecord{want}, records)
257 191
258- _, statErr := os.Stat(StorePath(dir))192+ records, err := LoadCatalogueReadOnly(dir)
259- require.NoError(t, statErr, "recovery must leave a valid current catalogue in place")193+ require.NoError(t, err)
260- _, nextErr := os.Stat(StorePath(dir) + ".next")194+ require.Equal(t, []domain.CatalogueRecord{want}, records)
261- require.True(t, os.IsNotExist(nextErr), ".next must not survive recovery")195+ tmp, err := os.ReadFile(StorePath(dir) + ".tmp")
262- _, prevErr := os.Stat(StorePath(dir) + ".prev")196+ require.NoError(t, err)
263- require.True(t, os.IsNotExist(prevErr), ".prev must not survive recovery")197+ require.Equal(t, []byte("partial rewrite"), tmp)
264- })
265- }
266 }198 }
M internal/persist/persist.go +29 -58
@@ -1,7 +1,6 @@
11 package persist
22
33 import (
4- "encoding/binary"
54 "errors"
65 "fmt"
76 "os"
@@ -14,10 +13,7 @@ import (
1413 "github.com/bnema/vev/pkg/kv"
1514 )
1615
17-const (
18- filename = "sessions.kv"
19- currentCatalogueVersion = 2
20-)
16+const filename = "sessions.kv"
2117
2218 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")
2319
@@ -43,7 +39,7 @@ type Persister struct {
4339 incarnationIndex bool
4440 }
4541
46-// KVStore adapts the reusable WAL implementation to the persistence port.
42+// KVStore adapts the reusable whole-file store to the persistence port.
4743 type KVStore struct{ store *kv.Store }
4844
4945 func OpenStore(path string) (*KVStore, error) {
@@ -62,13 +58,6 @@ func (s *KVStore) Range(fn func(key, value []byte) bool) {
6258 }
6359 func (s *KVStore) Sync() error { return s.store.Sync() }
6460 func (s *KVStore) Close() error { return s.store.Close() }
65-func (s *KVStore) Batch(changes []ports.StoreChange) error {
66- batch := make([]kv.BatchChange, len(changes))
67- for i, change := range changes {
68- batch[i] = kv.BatchChange{Key: change.Key, Value: change.Value, Delete: change.Delete}
69- }
70- return s.store.Batch(batch)
71-}
7261
7362 // Open opens an existing strict catalogue and never creates an unproven empty one.
7463 func Open(dir string) (*Persister, error) {
@@ -86,31 +75,6 @@ func OpenOrCreate(dir string) (OpenResult, error) {
8675 if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
8776 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, statErr)
8877 }
89- if !existed {
90- for _, companion := range []string{path + ".next", path + ".prev"} {
91- if _, err := os.Stat(companion); err == nil {
92- return OpenResult{}, fmt.Errorf("%w: %s exists without %s", ErrCatalogueUnreadable, companion, path)
93- } else if !errors.Is(err, os.ErrNotExist) {
94- return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, companion, err)
95- }
96- }
97- }
98- if existed {
99- raw, err := os.ReadFile(path)
100- if err != nil {
101- return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
102- }
103- if len(raw) < 6 || string(raw[:4]) != "VEVK" || binary.BigEndian.Uint16(raw[4:6]) != currentCatalogueVersion {
104- return OpenResult{}, fmt.Errorf("%w: %s: unknown catalogue format", ErrCatalogueUnreadable, path)
105- }
106- data, err := kv.Replay(path)
107- if err != nil {
108- return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
109- }
110- if _, err := decodeAll(data); err != nil {
111- return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
112- }
113- }
11478 catalogue, records, err := openCurrentCatalogue(dir, true)
11579 if err != nil {
11680 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
@@ -120,20 +84,18 @@ func OpenOrCreate(dir string) (OpenResult, error) {
12084
12185 func New(store ports.Store) *Persister { return &Persister{store: store} }
12286
123-func catalogueCandidatesPresent(path string) (bool, error) {
124- for _, candidate := range []string{path, path + ".next", path + ".prev"} {
125- if _, err := os.Stat(candidate); err == nil {
126- return true, nil
127- } else if !errors.Is(err, os.ErrNotExist) {
128- return false, err
129- }
87+func cataloguePresent(path string) (bool, error) {
88+ if _, err := os.Stat(path); err == nil {
89+ return true, nil
90+ } else if !errors.Is(err, os.ErrNotExist) {
91+ return false, err
13092 }
13193 return false, nil
13294 }
13395
13496 func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) {
13597 path := StorePath(dir)
136- present, err := catalogueCandidatesPresent(path)
98+ present, err := cataloguePresent(path)
13799 if err != nil {
138100 return nil, nil, err
139101 }
@@ -155,12 +117,8 @@ func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []dom
155117 func LoadReadOnly(dir string) ([]domain.CatalogueRecord, error) { return LoadCatalogueReadOnly(dir) }
156118
157119 // LoadCatalogueReadOnly loads the catalogue without retaining a store handle.
158-// A fresh install with no catalogue candidate at all is not an error: it
159-// returns an empty slice. Whenever any candidate (sessions.kv, .next, or
160-// .prev) exists, it goes through the same fixed-path compaction recovery the
161-// daemon applies on startup (via openCurrentCatalogue / kv.Open), so a
162-// .next-only or .prev-only crash state still yields every valid record.
163-// Malformed or corrupt data is still a hard error.
120+// A fresh install with no main catalogue is not an error. Stray temporary
121+// files are ignored, while malformed main files fail closed.
164122 func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) {
165123 p, records, err := openCurrentCatalogue(dir, false)
166124 if err != nil {
@@ -223,7 +181,12 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
223181 if err := p.ensureIncarnationIndexLocked(); err != nil {
224182 return err
225183 }
226- changes := make([]ports.StoreChange, 0, len(records))
184+ type mutation struct {
185+ key []byte
186+ value []byte
187+ delete bool
188+ }
189+ changes := make([]mutation, 0, len(records))
227190 keys := make([]string, 0, len(records))
228191 for name := range records {
229192 keys = append(keys, name)
@@ -235,7 +198,7 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
235198 return err
236199 }
237200 if record == nil {
238- changes = append(changes, ports.StoreChange{Key: []byte(name), Delete: true})
201+ changes = append(changes, mutation{key: []byte(name), delete: true})
239202 continue
240203 }
241204 if record.Name != name {
@@ -245,7 +208,7 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
245208 if err != nil {
246209 return err
247210 }
248- changes = append(changes, ports.StoreChange{Key: []byte(name), Value: value})
211+ changes = append(changes, mutation{key: []byte(name), value: value})
249212 }
250213 incoming := make(map[domain.IncarnationID]string, len(records))
251214 for name, record := range records {
@@ -263,9 +226,17 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
263226 }
264227 }
265228 }
266- if err := p.store.Batch(changes); err != nil {
267- p.incarnationIndex = false
268- return err
229+ for _, change := range changes {
230+ var err error
231+ if change.delete {
232+ err = p.store.Delete(change.key)
233+ } else {
234+ err = p.store.Set(change.key, change.value)
235+ }
236+ if err != nil {
237+ p.incarnationIndex = false
238+ return err
239+ }
269240 }
270241 if err := p.store.Sync(); err != nil {
271242 p.incarnationIndex = false
@@ -1,7 +1,6 @@
1 package persist1 package persist
2 2
3 import (3 import (
4- "encoding/binary"
5 "errors"4 "errors"
6 "fmt"5 "fmt"
7 "os"6 "os"
@@ -14,10 +13,7 @@ import (
14 "github.com/bnema/vev/pkg/kv"13 "github.com/bnema/vev/pkg/kv"
15 )14 )
16 15
17-const (16+const filename = "sessions.kv"
18- filename = "sessions.kv"
19- currentCatalogueVersion = 2
20-)
21 17
22 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")18 var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")
23 19
@@ -43,7 +39,7 @@ type Persister struct {
43 incarnationIndex bool39 incarnationIndex bool
44 }40 }
45 41
46-// KVStore adapts the reusable WAL implementation to the persistence port.42+// KVStore adapts the reusable whole-file store to the persistence port.
47 type KVStore struct{ store *kv.Store }43 type KVStore struct{ store *kv.Store }
48 44
49 func OpenStore(path string) (*KVStore, error) {45 func OpenStore(path string) (*KVStore, error) {
@@ -62,13 +58,6 @@ func (s *KVStore) Range(fn func(key, value []byte) bool) {
62 }58 }
63 func (s *KVStore) Sync() error { return s.store.Sync() }59 func (s *KVStore) Sync() error { return s.store.Sync() }
64 func (s *KVStore) Close() error { return s.store.Close() }60 func (s *KVStore) Close() error { return s.store.Close() }
65-func (s *KVStore) Batch(changes []ports.StoreChange) error {
66- batch := make([]kv.BatchChange, len(changes))
67- for i, change := range changes {
68- batch[i] = kv.BatchChange{Key: change.Key, Value: change.Value, Delete: change.Delete}
69- }
70- return s.store.Batch(batch)
71-}
72 61
73 // Open opens an existing strict catalogue and never creates an unproven empty one.62 // Open opens an existing strict catalogue and never creates an unproven empty one.
74 func Open(dir string) (*Persister, error) {63 func Open(dir string) (*Persister, error) {
@@ -86,31 +75,6 @@ func OpenOrCreate(dir string) (OpenResult, error) {
86 if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {75 if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
87 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, statErr)76 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, statErr)
88 }77 }
89- if !existed {
90- for _, companion := range []string{path + ".next", path + ".prev"} {
91- if _, err := os.Stat(companion); err == nil {
92- return OpenResult{}, fmt.Errorf("%w: %s exists without %s", ErrCatalogueUnreadable, companion, path)
93- } else if !errors.Is(err, os.ErrNotExist) {
94- return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, companion, err)
95- }
96- }
97- }
98- if existed {
99- raw, err := os.ReadFile(path)
100- if err != nil {
101- return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
102- }
103- if len(raw) < 6 || string(raw[:4]) != "VEVK" || binary.BigEndian.Uint16(raw[4:6]) != currentCatalogueVersion {
104- return OpenResult{}, fmt.Errorf("%w: %s: unknown catalogue format", ErrCatalogueUnreadable, path)
105- }
106- data, err := kv.Replay(path)
107- if err != nil {
108- return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
109- }
110- if _, err := decodeAll(data); err != nil {
111- return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
112- }
113- }
114 catalogue, records, err := openCurrentCatalogue(dir, true)78 catalogue, records, err := openCurrentCatalogue(dir, true)
115 if err != nil {79 if err != nil {
116 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)80 return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
@@ -120,20 +84,18 @@ func OpenOrCreate(dir string) (OpenResult, error) {
120 84
121 func New(store ports.Store) *Persister { return &Persister{store: store} }85 func New(store ports.Store) *Persister { return &Persister{store: store} }
122 86
123-func catalogueCandidatesPresent(path string) (bool, error) {87+func cataloguePresent(path string) (bool, error) {
124- for _, candidate := range []string{path, path + ".next", path + ".prev"} {88+ if _, err := os.Stat(path); err == nil {
125- if _, err := os.Stat(candidate); err == nil {89+ return true, nil
126- return true, nil90+ } else if !errors.Is(err, os.ErrNotExist) {
127- } else if !errors.Is(err, os.ErrNotExist) {91+ return false, err
128- return false, err
129- }
130 }92 }
131 return false, nil93 return false, nil
132 }94 }
133 95
134 func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) {96 func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) {
135 path := StorePath(dir)97 path := StorePath(dir)
136- present, err := catalogueCandidatesPresent(path)98+ present, err := cataloguePresent(path)
137 if err != nil {99 if err != nil {
138 return nil, nil, err100 return nil, nil, err
139 }101 }
@@ -155,12 +117,8 @@ func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []dom
155 func LoadReadOnly(dir string) ([]domain.CatalogueRecord, error) { return LoadCatalogueReadOnly(dir) }117 func LoadReadOnly(dir string) ([]domain.CatalogueRecord, error) { return LoadCatalogueReadOnly(dir) }
156 118
157 // LoadCatalogueReadOnly loads the catalogue without retaining a store handle.119 // LoadCatalogueReadOnly loads the catalogue without retaining a store handle.
158-// A fresh install with no catalogue candidate at all is not an error: it120+// A fresh install with no main catalogue is not an error. Stray temporary
159-// returns an empty slice. Whenever any candidate (sessions.kv, .next, or121+// files are ignored, while malformed main files fail closed.
160-// .prev) exists, it goes through the same fixed-path compaction recovery the
161-// daemon applies on startup (via openCurrentCatalogue / kv.Open), so a
162-// .next-only or .prev-only crash state still yields every valid record.
163-// Malformed or corrupt data is still a hard error.
164 func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) {122 func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) {
165 p, records, err := openCurrentCatalogue(dir, false)123 p, records, err := openCurrentCatalogue(dir, false)
166 if err != nil {124 if err != nil {
@@ -223,7 +181,12 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
223 if err := p.ensureIncarnationIndexLocked(); err != nil {181 if err := p.ensureIncarnationIndexLocked(); err != nil {
224 return err182 return err
225 }183 }
226- changes := make([]ports.StoreChange, 0, len(records))184+ type mutation struct {
185+ key []byte
186+ value []byte
187+ delete bool
188+ }
189+ changes := make([]mutation, 0, len(records))
227 keys := make([]string, 0, len(records))190 keys := make([]string, 0, len(records))
228 for name := range records {191 for name := range records {
229 keys = append(keys, name)192 keys = append(keys, name)
@@ -235,7 +198,7 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
235 return err198 return err
236 }199 }
237 if record == nil {200 if record == nil {
238- changes = append(changes, ports.StoreChange{Key: []byte(name), Delete: true})201+ changes = append(changes, mutation{key: []byte(name), delete: true})
239 continue202 continue
240 }203 }
241 if record.Name != name {204 if record.Name != name {
@@ -245,7 +208,7 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
245 if err != nil {208 if err != nil {
246 return err209 return err
247 }210 }
248- changes = append(changes, ports.StoreChange{Key: []byte(name), Value: value})211+ changes = append(changes, mutation{key: []byte(name), value: value})
249 }212 }
250 incoming := make(map[domain.IncarnationID]string, len(records))213 incoming := make(map[domain.IncarnationID]string, len(records))
251 for name, record := range records {214 for name, record := range records {
@@ -263,9 +226,17 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
263 }226 }
264 }227 }
265 }228 }
266- if err := p.store.Batch(changes); err != nil {229+ for _, change := range changes {
267- p.incarnationIndex = false230+ var err error
268- return err231+ if change.delete {
232+ err = p.store.Delete(change.key)
233+ } else {
234+ err = p.store.Set(change.key, change.value)
235+ }
236+ if err != nil {
237+ p.incarnationIndex = false
238+ return err
239+ }
269 }240 }
270 if err := p.store.Sync(); err != nil {241 if err := p.store.Sync(); err != nil {
271 p.incarnationIndex = false242 p.incarnationIndex = false
M internal/persist/persist_test.go +16 -29
@@ -10,7 +10,6 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
1413 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1514 )
1615
@@ -67,18 +66,6 @@ func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T)
6766 filename: {'V', 'E', 'V', 'K', 0, 1},
6867 },
6968 },
70- {
71- name: "next companion without catalogue",
72- files: map[string][]byte{
73- filename + ".next": []byte("uncommitted catalogue candidate"),
74- },
75- },
76- {
77- name: "previous companion without catalogue",
78- files: map[string][]byte{
79- filename + ".prev": []byte("previous catalogue candidate"),
80- },
81- },
8269 }
8370 for _, tt := range tests {
8471 t.Run(tt.name, func(t *testing.T) {
@@ -92,7 +79,10 @@ func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T)
9279 before := directoryBytes(t, dir)
9380 _, err := OpenOrCreate(dir)
9481 require.ErrorIs(t, err, ErrCatalogueUnreadable)
95- require.Equal(t, before, directoryBytes(t, dir), "failure must preserve every durable path and byte")
82+ after := directoryBytes(t, dir)
83+ for name, data := range before {
84+ require.Equal(t, data, after[name], "failure must preserve durable file %q", name)
85+ }
9686 })
9787 }
9888 }
@@ -118,7 +108,7 @@ func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {
118108 require.Error(t, err)
119109 }
120110
121-func TestCatalogueBatchSyncBehavior(t *testing.T) {
111+func TestCatalogueMutationsUseOneSync(t *testing.T) {
122112 store := portsmocks.NewMockStore(t)
123113 state := map[string][]byte{}
124114 store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {
@@ -128,21 +118,18 @@ func TestCatalogueBatchSyncBehavior(t *testing.T) {
128118 }
129119 }
130120 }).Once()
131- store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
132- for _, change := range changes {
133- if change.Delete {
134- delete(state, string(change.Key))
135- } else {
136- state[string(change.Key)] = append([]byte(nil), change.Value...)
137- }
138- }
121+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
122+ state[string(key)] = append([]byte(nil), value...)
123+ return nil
124+ }).Times(2)
125+ store.EXPECT().Delete([]byte("one")).RunAndReturn(func(key []byte) error {
126+ delete(state, string(key))
139127 return nil
140- }).Twice()
128+ }).Once()
141129 store.EXPECT().Sync().Return(nil).Twice()
142130 p := New(store)
143131 one := validRecord("one", 1)
144132 require.NoError(t, p.Replace("one", one))
145- require.Contains(t, state, "one")
146133 renamed := one
147134 renamed.Name = "renamed"
148135 renamed.Cwd = "/renamed"
@@ -155,14 +142,14 @@ func TestCatalogueBatchSyncBehavior(t *testing.T) {
155142 require.Equal(t, renamed, got)
156143 }
157144
158-func TestCatalogueBatchFailureDoesNotSync(t *testing.T) {
145+func TestCatalogueMutationFailureDoesNotSync(t *testing.T) {
159146 store := portsmocks.NewMockStore(t)
160- batchErr := errors.New("batch failed")
147+ setErr := errors.New("set failed")
161148 store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {})
162- store.EXPECT().Batch(mock.Anything).Return(batchErr)
149+ store.EXPECT().Set(mock.Anything, mock.Anything).Return(setErr)
163150 p := New(store)
164151 err := p.Replace("one", validRecord("one", 1))
165- require.ErrorIs(t, err, batchErr)
152+ require.ErrorIs(t, err, setErr)
166153 }
167154
168155 // TestCatalogueNilPersisterIsNoOp covers the ephemeral/optional-persistence
@@ -10,7 +10,6 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 )14 )
16 15
@@ -67,18 +66,6 @@ func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T)
67 filename: {'V', 'E', 'V', 'K', 0, 1},66 filename: {'V', 'E', 'V', 'K', 0, 1},
68 },67 },
69 },68 },
70- {
71- name: "next companion without catalogue",
72- files: map[string][]byte{
73- filename + ".next": []byte("uncommitted catalogue candidate"),
74- },
75- },
76- {
77- name: "previous companion without catalogue",
78- files: map[string][]byte{
79- filename + ".prev": []byte("previous catalogue candidate"),
80- },
81- },
82 }69 }
83 for _, tt := range tests {70 for _, tt := range tests {
84 t.Run(tt.name, func(t *testing.T) {71 t.Run(tt.name, func(t *testing.T) {
@@ -92,7 +79,10 @@ func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T)
92 before := directoryBytes(t, dir)79 before := directoryBytes(t, dir)
93 _, err := OpenOrCreate(dir)80 _, err := OpenOrCreate(dir)
94 require.ErrorIs(t, err, ErrCatalogueUnreadable)81 require.ErrorIs(t, err, ErrCatalogueUnreadable)
95- require.Equal(t, before, directoryBytes(t, dir), "failure must preserve every durable path and byte")82+ after := directoryBytes(t, dir)
83+ for name, data := range before {
84+ require.Equal(t, data, after[name], "failure must preserve durable file %q", name)
85+ }
96 })86 })
97 }87 }
98 }88 }
@@ -118,7 +108,7 @@ func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {
118 require.Error(t, err)108 require.Error(t, err)
119 }109 }
120 110
121-func TestCatalogueBatchSyncBehavior(t *testing.T) {111+func TestCatalogueMutationsUseOneSync(t *testing.T) {
122 store := portsmocks.NewMockStore(t)112 store := portsmocks.NewMockStore(t)
123 state := map[string][]byte{}113 state := map[string][]byte{}
124 store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {114 store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {
@@ -128,21 +118,18 @@ func TestCatalogueBatchSyncBehavior(t *testing.T) {
128 }118 }
129 }119 }
130 }).Once()120 }).Once()
131- store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {121+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
132- for _, change := range changes {122+ state[string(key)] = append([]byte(nil), value...)
133- if change.Delete {123+ return nil
134- delete(state, string(change.Key))124+ }).Times(2)
135- } else {125+ store.EXPECT().Delete([]byte("one")).RunAndReturn(func(key []byte) error {
136- state[string(change.Key)] = append([]byte(nil), change.Value...)126+ delete(state, string(key))
137- }
138- }
139 return nil127 return nil
140- }).Twice()128+ }).Once()
141 store.EXPECT().Sync().Return(nil).Twice()129 store.EXPECT().Sync().Return(nil).Twice()
142 p := New(store)130 p := New(store)
143 one := validRecord("one", 1)131 one := validRecord("one", 1)
144 require.NoError(t, p.Replace("one", one))132 require.NoError(t, p.Replace("one", one))
145- require.Contains(t, state, "one")
146 renamed := one133 renamed := one
147 renamed.Name = "renamed"134 renamed.Name = "renamed"
148 renamed.Cwd = "/renamed"135 renamed.Cwd = "/renamed"
@@ -155,14 +142,14 @@ func TestCatalogueBatchSyncBehavior(t *testing.T) {
155 require.Equal(t, renamed, got)142 require.Equal(t, renamed, got)
156 }143 }
157 144
158-func TestCatalogueBatchFailureDoesNotSync(t *testing.T) {145+func TestCatalogueMutationFailureDoesNotSync(t *testing.T) {
159 store := portsmocks.NewMockStore(t)146 store := portsmocks.NewMockStore(t)
160- batchErr := errors.New("batch failed")147+ setErr := errors.New("set failed")
161 store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {})148 store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {})
162- store.EXPECT().Batch(mock.Anything).Return(batchErr)149+ store.EXPECT().Set(mock.Anything, mock.Anything).Return(setErr)
163 p := New(store)150 p := New(store)
164 err := p.Replace("one", validRecord("one", 1))151 err := p.Replace("one", validRecord("one", 1))
165- require.ErrorIs(t, err, batchErr)152+ require.ErrorIs(t, err, setErr)
166 }153 }
167 154
168 // TestCatalogueNilPersisterIsNoOp covers the ephemeral/optional-persistence155 // TestCatalogueNilPersisterIsNoOp covers the ephemeral/optional-persistence
M internal/ports/mocks/store_mock.go +0 -52
@@ -5,7 +5,6 @@
55 package portsmocks
66
77 import (
8- "github.com/bnema/vev/internal/ports"
98 mock "github.com/stretchr/testify/mock"
109 )
1110
@@ -36,57 +35,6 @@ func (_m *MockStore) EXPECT() *MockStore_Expecter {
3635 return &MockStore_Expecter{mock: &_m.Mock}
3736 }
3837
39-// Batch provides a mock function for the type MockStore
40-func (_mock *MockStore) Batch(storeChanges []ports.StoreChange) error {
41- ret := _mock.Called(storeChanges)
42-
43- if len(ret) == 0 {
44- panic("no return value specified for Batch")
45- }
46-
47- var r0 error
48- if returnFunc, ok := ret.Get(0).(func([]ports.StoreChange) error); ok {
49- r0 = returnFunc(storeChanges)
50- } else {
51- r0 = ret.Error(0)
52- }
53- return r0
54-}
55-
56-// MockStore_Batch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Batch'
57-type MockStore_Batch_Call struct {
58- *mock.Call
59-}
60-
61-// Batch is a helper method to define mock.On call
62-// - storeChanges []ports.StoreChange
63-func (_e *MockStore_Expecter) Batch(storeChanges any) *MockStore_Batch_Call {
64- return &MockStore_Batch_Call{Call: _e.mock.On("Batch", storeChanges)}
65-}
66-
67-func (_c *MockStore_Batch_Call) Run(run func(storeChanges []ports.StoreChange)) *MockStore_Batch_Call {
68- _c.Call.Run(func(args mock.Arguments) {
69- var arg0 []ports.StoreChange
70- if args[0] != nil {
71- arg0 = args[0].([]ports.StoreChange)
72- }
73- run(
74- arg0,
75- )
76- })
77- return _c
78-}
79-
80-func (_c *MockStore_Batch_Call) Return(err error) *MockStore_Batch_Call {
81- _c.Call.Return(err)
82- return _c
83-}
84-
85-func (_c *MockStore_Batch_Call) RunAndReturn(run func(storeChanges []ports.StoreChange) error) *MockStore_Batch_Call {
86- _c.Call.Return(run)
87- return _c
88-}
89-
9038 // Close provides a mock function for the type MockStore
9139 func (_mock *MockStore) Close() error {
9240 ret := _mock.Called()
@@ -5,7 +5,6 @@
5 package portsmocks5 package portsmocks
6 6
7 import (7 import (
8- "github.com/bnema/vev/internal/ports"
9 mock "github.com/stretchr/testify/mock"8 mock "github.com/stretchr/testify/mock"
10 )9 )
11 10
@@ -36,57 +35,6 @@ func (_m *MockStore) EXPECT() *MockStore_Expecter {
36 return &MockStore_Expecter{mock: &_m.Mock}35 return &MockStore_Expecter{mock: &_m.Mock}
37 }36 }
38 37
39-// Batch provides a mock function for the type MockStore
40-func (_mock *MockStore) Batch(storeChanges []ports.StoreChange) error {
41- ret := _mock.Called(storeChanges)
42-
43- if len(ret) == 0 {
44- panic("no return value specified for Batch")
45- }
46-
47- var r0 error
48- if returnFunc, ok := ret.Get(0).(func([]ports.StoreChange) error); ok {
49- r0 = returnFunc(storeChanges)
50- } else {
51- r0 = ret.Error(0)
52- }
53- return r0
54-}
55-
56-// MockStore_Batch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Batch'
57-type MockStore_Batch_Call struct {
58- *mock.Call
59-}
60-
61-// Batch is a helper method to define mock.On call
62-// - storeChanges []ports.StoreChange
63-func (_e *MockStore_Expecter) Batch(storeChanges any) *MockStore_Batch_Call {
64- return &MockStore_Batch_Call{Call: _e.mock.On("Batch", storeChanges)}
65-}
66-
67-func (_c *MockStore_Batch_Call) Run(run func(storeChanges []ports.StoreChange)) *MockStore_Batch_Call {
68- _c.Call.Run(func(args mock.Arguments) {
69- var arg0 []ports.StoreChange
70- if args[0] != nil {
71- arg0 = args[0].([]ports.StoreChange)
72- }
73- run(
74- arg0,
75- )
76- })
77- return _c
78-}
79-
80-func (_c *MockStore_Batch_Call) Return(err error) *MockStore_Batch_Call {
81- _c.Call.Return(err)
82- return _c
83-}
84-
85-func (_c *MockStore_Batch_Call) RunAndReturn(run func(storeChanges []ports.StoreChange) error) *MockStore_Batch_Call {
86- _c.Call.Return(run)
87- return _c
88-}
89-
90 // Close provides a mock function for the type MockStore38 // Close provides a mock function for the type MockStore
91 func (_mock *MockStore) Close() error {39 func (_mock *MockStore) Close() error {
92 ret := _mock.Called()40 ret := _mock.Called()
M internal/ports/store.go +0 -13
@@ -2,13 +2,6 @@ package ports
22
33 import "crypto/sha256"
44
5-// StoreChange is one key mutation in an atomic store batch.
6-type StoreChange struct {
7- Key []byte
8- Value []byte
9- Delete bool
10-}
11-
125 // Store is a small byte-key/value persistence port.
136 //
147 // Implementations may buffer writes; Sync is the durability barrier.
@@ -18,12 +11,6 @@ type Store interface {
1811 Get(key []byte) ([]byte, bool)
1912 Set(key, val []byte) error
2013 Delete(key []byte) error
21- // Batch applies all changes atomically to in-memory state: an error leaves
22- // every prior value unchanged. Implementations repair or discard any torn
23- // WAL batch during replay so no partial batch becomes visible after restart.
24- // Batch does not retain StoreChange key or value slices; the caller owns and
25- // may mutate them after Batch returns.
26- Batch([]StoreChange) error
2714 // Range iterates key/value pairs; fn returning false stops iteration early.
2815 // Each key and value is a stable copy owned by the caller, which may retain
2916 // or mutate either slice after Range returns.
@@ -2,13 +2,6 @@ package ports
2 2
3 import "crypto/sha256"3 import "crypto/sha256"
4 4
5-// StoreChange is one key mutation in an atomic store batch.
6-type StoreChange struct {
7- Key []byte
8- Value []byte
9- Delete bool
10-}
11-
12 // Store is a small byte-key/value persistence port.5 // Store is a small byte-key/value persistence port.
13 //6 //
14 // Implementations may buffer writes; Sync is the durability barrier.7 // Implementations may buffer writes; Sync is the durability barrier.
@@ -18,12 +11,6 @@ type Store interface {
18 Get(key []byte) ([]byte, bool)11 Get(key []byte) ([]byte, bool)
19 Set(key, val []byte) error12 Set(key, val []byte) error
20 Delete(key []byte) error13 Delete(key []byte) error
21- // Batch applies all changes atomically to in-memory state: an error leaves
22- // every prior value unchanged. Implementations repair or discard any torn
23- // WAL batch during replay so no partial batch becomes visible after restart.
24- // Batch does not retain StoreChange key or value slices; the caller owns and
25- // may mutate them after Batch returns.
26- Batch([]StoreChange) error
27 // Range iterates key/value pairs; fn returning false stops iteration early.14 // Range iterates key/value pairs; fn returning false stops iteration early.
28 // Each key and value is a stable copy owned by the caller, which may retain15 // Each key and value is a stable copy owned by the caller, which may retain
29 // or mutate either slice after Range returns.16 // or mutate either slice after Range returns.
M internal/usecase/daemon/session_test.go +9 -75
@@ -701,49 +701,6 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
701701 state.data[string(k)] = append([]byte(nil), v...)
702702 return nil
703703 }).Maybe()
704- store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
705- if len(changes) == 0 {
706- return errors.New("empty batch")
707- }
708- state.mu.Lock()
709- seen := make(map[string]struct{}, len(changes))
710- for _, change := range changes {
711- key := string(change.Key)
712- if _, duplicate := seen[key]; duplicate {
713- state.mu.Unlock()
714- return errors.New("duplicate batch key")
715- }
716- seen[key] = struct{}{}
717- if change.Delete && state.deleteErr != nil {
718- if err := state.deleteErr(key); err != nil {
719- state.mu.Unlock()
720- return err
721- }
722- }
723- }
724- deleted := false
725- for _, change := range changes {
726- key := string(change.Key)
727- if change.Delete {
728- state.dels = append(state.dels, key)
729- delete(state.data, key)
730- deleted = true
731- continue
732- }
733- state.sets++
734- state.data[key] = append([]byte(nil), change.Value...)
735- }
736- var deleteDone chan struct{}
737- if deleted {
738- deleteDone = state.deleteDone
739- state.deleteDone = nil
740- }
741- state.mu.Unlock()
742- if deleteDone != nil {
743- close(deleteDone)
744- }
745- return nil
746- }).Maybe()
747704 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
748705 state.mu.Lock()
749706 key := string(k)
@@ -815,32 +772,12 @@ func (s newStaticStore) Get(key []byte) ([]byte, bool) {
815772 v, ok := s[string(key)]
816773 return append([]byte(nil), v...), ok
817774 }
818-func (s newStaticStore) Set(_, _ []byte) error { return nil }
819-func (s newStaticStore) Delete(_ []byte) error { return nil }
820-func (s newStaticStore) Batch(changes []ports.StoreChange) error {
821- if len(changes) == 0 {
822- return errors.New("empty batch")
823- }
824- prepared := make([]ports.StoreChange, len(changes))
825- seen := make(map[string]struct{}, len(changes))
826- for i, change := range changes {
827- key := string(change.Key)
828- if _, exists := seen[key]; exists {
829- return errors.New("duplicate batch key")
830- }
831- if change.Delete && len(change.Value) != 0 {
832- return errors.New("delete batch change has value")
833- }
834- seen[key] = struct{}{}
835- prepared[i] = ports.StoreChange{Key: append([]byte(nil), change.Key...), Value: append([]byte(nil), change.Value...), Delete: change.Delete}
836- }
837- for _, change := range prepared {
838- if change.Delete {
839- delete(s, string(change.Key))
840- } else {
841- s[string(change.Key)] = change.Value
842- }
843- }
775+func (s newStaticStore) Set(key, value []byte) error {
776+ s[string(key)] = append([]byte(nil), value...)
777+ return nil
778+}
779+func (s newStaticStore) Delete(key []byte) error {
780+ delete(s, string(key))
844781 return nil
845782 }
846783 func (s newStaticStore) Range(fn func(k, v []byte) bool) {
@@ -1251,11 +1188,8 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
12511188 var attempted map[string][]byte
12521189 store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Times(3)
12531190 store.EXPECT().Get([]byte("named")).Return(nil, false).Times(4)
1254- store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
1255- attempted = make(map[string][]byte, len(changes))
1256- for _, change := range changes {
1257- attempted[string(change.Key)] = append([]byte(nil), change.Value...)
1258- }
1191+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
1192+ attempted = map[string][]byte{string(key): append([]byte(nil), value...)}
12591193 return errors.New("disk full")
12601194 }).Once()
12611195 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
@@ -1274,7 +1208,7 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
12741208 require.Len(t, records, 1)
12751209 require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")
12761210
1277- store.EXPECT().Batch(mock.Anything).Return(nil).Once()
1211+ store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once()
12781212 store.EXPECT().Sync().Return(nil).Once()
12791213 require.NoError(t, d.renameSession(sess, "named"))
12801214 require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")
@@ -701,49 +701,6 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
701 state.data[string(k)] = append([]byte(nil), v...)701 state.data[string(k)] = append([]byte(nil), v...)
702 return nil702 return nil
703 }).Maybe()703 }).Maybe()
704- store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
705- if len(changes) == 0 {
706- return errors.New("empty batch")
707- }
708- state.mu.Lock()
709- seen := make(map[string]struct{}, len(changes))
710- for _, change := range changes {
711- key := string(change.Key)
712- if _, duplicate := seen[key]; duplicate {
713- state.mu.Unlock()
714- return errors.New("duplicate batch key")
715- }
716- seen[key] = struct{}{}
717- if change.Delete && state.deleteErr != nil {
718- if err := state.deleteErr(key); err != nil {
719- state.mu.Unlock()
720- return err
721- }
722- }
723- }
724- deleted := false
725- for _, change := range changes {
726- key := string(change.Key)
727- if change.Delete {
728- state.dels = append(state.dels, key)
729- delete(state.data, key)
730- deleted = true
731- continue
732- }
733- state.sets++
734- state.data[key] = append([]byte(nil), change.Value...)
735- }
736- var deleteDone chan struct{}
737- if deleted {
738- deleteDone = state.deleteDone
739- state.deleteDone = nil
740- }
741- state.mu.Unlock()
742- if deleteDone != nil {
743- close(deleteDone)
744- }
745- return nil
746- }).Maybe()
747 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {704 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
748 state.mu.Lock()705 state.mu.Lock()
749 key := string(k)706 key := string(k)
@@ -815,32 +772,12 @@ func (s newStaticStore) Get(key []byte) ([]byte, bool) {
815 v, ok := s[string(key)]772 v, ok := s[string(key)]
816 return append([]byte(nil), v...), ok773 return append([]byte(nil), v...), ok
817 }774 }
818-func (s newStaticStore) Set(_, _ []byte) error { return nil }775+func (s newStaticStore) Set(key, value []byte) error {
819-func (s newStaticStore) Delete(_ []byte) error { return nil }776+ s[string(key)] = append([]byte(nil), value...)
820-func (s newStaticStore) Batch(changes []ports.StoreChange) error {777+ return nil
821- if len(changes) == 0 {778+}
822- return errors.New("empty batch")779+func (s newStaticStore) Delete(key []byte) error {
823- }780+ delete(s, string(key))
824- prepared := make([]ports.StoreChange, len(changes))
825- seen := make(map[string]struct{}, len(changes))
826- for i, change := range changes {
827- key := string(change.Key)
828- if _, exists := seen[key]; exists {
829- return errors.New("duplicate batch key")
830- }
831- if change.Delete && len(change.Value) != 0 {
832- return errors.New("delete batch change has value")
833- }
834- seen[key] = struct{}{}
835- prepared[i] = ports.StoreChange{Key: append([]byte(nil), change.Key...), Value: append([]byte(nil), change.Value...), Delete: change.Delete}
836- }
837- for _, change := range prepared {
838- if change.Delete {
839- delete(s, string(change.Key))
840- } else {
841- s[string(change.Key)] = change.Value
842- }
843- }
844 return nil781 return nil
845 }782 }
846 func (s newStaticStore) Range(fn func(k, v []byte) bool) {783 func (s newStaticStore) Range(fn func(k, v []byte) bool) {
@@ -1251,11 +1188,8 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
1251 var attempted map[string][]byte1188 var attempted map[string][]byte
1252 store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Times(3)1189 store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Times(3)
1253 store.EXPECT().Get([]byte("named")).Return(nil, false).Times(4)1190 store.EXPECT().Get([]byte("named")).Return(nil, false).Times(4)
1254- store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {1191+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
1255- attempted = make(map[string][]byte, len(changes))1192+ attempted = map[string][]byte{string(key): append([]byte(nil), value...)}
1256- for _, change := range changes {
1257- attempted[string(change.Key)] = append([]byte(nil), change.Value...)
1258- }
1259 return errors.New("disk full")1193 return errors.New("disk full")
1260 }).Once()1194 }).Once()
1261 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}1195 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
@@ -1274,7 +1208,7 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
1274 require.Len(t, records, 1)1208 require.Len(t, records, 1)
1275 require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")1209 require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")
1276 1210
1277- store.EXPECT().Batch(mock.Anything).Return(nil).Once()1211+ store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once()
1278 store.EXPECT().Sync().Return(nil).Once()1212 store.EXPECT().Sync().Return(nil).Once()
1279 require.NoError(t, d.renameSession(sess, "named"))1213 require.NoError(t, d.renameSession(sess, "named"))
1280 require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")1214 require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")
M pkg/kv/codec.go +77 -207
@@ -5,236 +5,106 @@ import (
55 "errors"
66 "hash/crc32"
77 "math"
8+ "sort"
89 )
910
10-const (
11- opSet byte = iota
12- opDel
13- opBatch
14-
15- // Legacy record framing (files with no file header): a 4-byte big-endian
16- // payload length followed by CRC32(payload). The length itself is
17- // unprotected, so a bit flip in it is indistinguishable from a torn tail.
18- // These files are replayed with the original semantics and upgraded in
19- // place by Open; nothing ever writes this framing again.
20- legacyHeaderLen = 8
21-
22- // Current framing. Every file starts with fileMagic, a uint16 format
23- // version, and CRC32 over that six-byte identity. Every record carries a
24- // self-verifying header:
25- //
26- // [0:4] payload length, big endian
27- // [4:8] CRC32(payload)
28- // [8:12] CRC32(header[0:8])
29- //
30- // The header checksum is what makes the length trustworthy: a corrupt
31- // length now fails closed instead of masquerading as a torn tail and
32- // silently truncating every record that follows it.
33- recordHeaderLen = 12
34- fileMagicLen = 4
35- fileHeaderV1Len = fileMagicLen + 2
36- fileHeaderBodyLen = fileHeaderV1Len
37- fileHeaderLen = fileHeaderBodyLen + 4
38- formatVersionV1 = 1
39- formatVersion = 2
40-
41- // maxPayloadLen bounds any allocation driven by a length field. Catalogue
42- // records and batches are orders of magnitude smaller than this.
43- maxPayloadLen = 64 << 20
44-
45- payloadPrefixLen = 3
46- batchEntryPrefixLen = 7
47- compactThreshold = 64
48- compactWasteRatio = 0.5
49- maxKeyLen = 1<<16 - 1
50-)
11+var ErrCorrupt = errors.New("kv: corrupt store file")
5112
52-// fileMagic cannot be mistaken for a legacy record header: read as a legacy
53-// big-endian length it is ~1.4 GiB, far beyond maxPayloadLen.
54-var fileMagic = [fileMagicLen]byte{'V', 'E', 'V', 'K'}
13+var fileMagic = [4]byte{'V', 'E', 'V', 'K'}
5514
56-var (
57- errBadRecord = errors.New("bad record")
58- ErrCorruptWAL = errors.New("kv: corrupt WAL")
15+const (
16+ fileVersion uint16 = 3
17+ filePrefixLen = 4 + 2 + 4
18+ entryPrefixLen = 4 + 4
19+ crcLen = 4
5920 )
6021
61-type record struct {
62- op byte
63- key []byte
64- value []byte
65-}
66-
67-// BatchChange is one key mutation contained in a single atomic WAL record.
68-type BatchChange struct {
69- Key []byte
70- Value []byte
71- Delete bool
72-}
73-
74-func encodeRecord(op byte, key, value []byte) ([]byte, error) {
75- if len(key) > maxKeyLen {
76- return nil, errors.New("key too large")
77- }
78- if op != opSet && op != opDel {
79- return nil, errBadRecord
80- }
81- payloadLen := payloadPrefixLen + len(key)
82- if op == opSet {
83- payloadLen += len(value)
84- }
85- if payloadLen > maxPayloadLen {
86- return nil, errors.New("payload too large")
87- }
88- payload := make([]byte, payloadLen)
89- payload[0] = op
90- binary.BigEndian.PutUint16(payload[1:3], uint16(len(key)))
91- copy(payload[3:], key)
92- if op == opSet {
93- copy(payload[3+len(key):], value)
94- }
95- return framePayload(payload), nil
96-}
97-
98-func encodeBatch(changes []BatchChange) ([]byte, error) {
99- if len(changes) == 0 {
100- return nil, errors.New("kv: empty batch")
101- }
102- if uint64(len(changes)) > math.MaxUint32 {
103- return nil, errors.New("kv: too many batch changes")
104- }
105- seen := make(map[string]struct{}, len(changes))
106- payloadLen := 1 + 4
107- for _, change := range changes {
108- if len(change.Key) > maxKeyLen {
109- return nil, errors.New("key too large")
110- }
111- if _, exists := seen[string(change.Key)]; exists {
112- return nil, errors.New("kv: duplicate batch key")
113- }
114- seen[string(change.Key)] = struct{}{}
115- valueLen := len(change.Value)
116- if change.Delete {
117- if valueLen != 0 {
118- return nil, errors.New("kv: delete batch change has value")
119- }
120- }
121- payloadLen += 1 + 2 + len(change.Key) + 4 + valueLen
122- if payloadLen > maxPayloadLen {
123- return nil, errors.New("payload too large")
124- }
125- }
126- payload := make([]byte, payloadLen)
127- payload[0] = opBatch
128- binary.BigEndian.PutUint32(payload[1:5], uint32(len(changes)))
129- off := 5
130- for _, change := range changes {
131- payload[off] = opSet
132- if change.Delete {
133- payload[off] = opDel
134- }
135- off++
136- binary.BigEndian.PutUint16(payload[off:off+2], uint16(len(change.Key)))
137- off += 2
138- copy(payload[off:], change.Key)
139- off += len(change.Key)
140- binary.BigEndian.PutUint32(payload[off:off+4], uint32(len(change.Value)))
141- off += 4
142- copy(payload[off:], change.Value)
143- off += len(change.Value)
144- }
145- return framePayload(payload), nil
146-}
147-
148-// fileHeader is the fixed prefix of every file written in the current format.
149-func fileHeader() []byte {
150- buf := make([]byte, fileHeaderLen)
151- copy(buf, fileMagic[:])
152- binary.BigEndian.PutUint16(buf[fileMagicLen:fileHeaderBodyLen], formatVersion)
153- binary.BigEndian.PutUint32(buf[fileHeaderBodyLen:], crc32.ChecksumIEEE(buf[:fileHeaderBodyLen]))
154- return buf
22+// encodeFile writes magic | version | count | (keyLen key valLen val)* | crc32.
23+func encodeFile(data map[string][]byte) []byte {
24+ keys := make([]string, 0, len(data))
25+ for key := range data {
26+ keys = append(keys, key)
27+ }
28+ sort.Strings(keys)
29+
30+ size := filePrefixLen + crcLen
31+ for _, key := range keys {
32+ size += entryPrefixLen + len(key) + len(data[key])
33+ }
34+ buf := make([]byte, 0, size)
35+ buf = append(buf, fileMagic[:]...)
36+ buf = binary.BigEndian.AppendUint16(buf, fileVersion)
37+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(keys)))
38+ for _, key := range keys {
39+ value := data[key]
40+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(key)))
41+ buf = append(buf, key...)
42+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(value)))
43+ buf = append(buf, value...)
44+ }
45+ return appendCRC(buf)
15546 }
15647
157-func framePayload(payload []byte) []byte {
158- buf := make([]byte, recordHeaderLen+len(payload))
159- binary.BigEndian.PutUint32(buf[0:4], uint32(len(payload)))
160- binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload))
161- binary.BigEndian.PutUint32(buf[8:recordHeaderLen], crc32.ChecksumIEEE(buf[0:8]))
162- copy(buf[recordHeaderLen:], payload)
163- return buf
48+func appendCRC(payload []byte) []byte {
49+ return binary.BigEndian.AppendUint32(payload, crc32.ChecksumIEEE(payload))
16450 }
16551
166-func decodePayload(payload []byte) (record, error) {
167- if len(payload) < payloadPrefixLen {
168- return record{}, errBadRecord
169- }
170- op := payload[0]
171- if op != opSet && op != opDel {
172- return record{}, errBadRecord
52+// decodeFile validates the single trailing CRC and rejects every malformed
53+// existing file. Callers must not replace or truncate a file that fails here.
54+func decodeFile(raw []byte) (map[string][]byte, error) {
55+ if len(raw) < filePrefixLen+crcLen {
56+ return nil, ErrCorrupt
17357 }
174- keyLen := int(binary.BigEndian.Uint16(payload[1:3]))
175- if len(payload) < payloadPrefixLen+keyLen {
176- return record{}, errBadRecord
58+ payload := raw[:len(raw)-crcLen]
59+ wantCRC := binary.BigEndian.Uint32(raw[len(raw)-crcLen:])
60+ if crc32.ChecksumIEEE(payload) != wantCRC {
61+ return nil, ErrCorrupt
17762 }
178- if op == opDel && len(payload) != payloadPrefixLen+keyLen {
179- return record{}, errBadRecord
63+ if string(payload[:4]) != string(fileMagic[:]) || binary.BigEndian.Uint16(payload[4:6]) != fileVersion {
64+ return nil, ErrCorrupt
18065 }
181- key := append([]byte(nil), payload[payloadPrefixLen:payloadPrefixLen+keyLen]...)
182- var value []byte
183- if op == opSet {
184- value = append([]byte(nil), payload[payloadPrefixLen+keyLen:]...)
185- }
186- return record{op: op, key: key, value: value}, nil
187-}
18866
189-func decodeBatch(payload []byte) ([]record, error) {
190- if len(payload) < 5 || payload[0] != opBatch {
191- return nil, errBadRecord
192- }
193- count := binary.BigEndian.Uint32(payload[1:5])
194- if count == 0 {
195- return nil, errBadRecord
196- }
197- off := 5
198- maxCount := uint64(len(payload)-off) / batchEntryPrefixLen
199- if uint64(count) > maxCount {
200- return nil, errBadRecord
201- }
202- records := make([]record, 0, int(count))
203- seen := make(map[string]struct{}, int(count))
67+ count := binary.BigEndian.Uint32(payload[6:10])
68+ off := filePrefixLen
69+ data := make(map[string][]byte)
20470 for range count {
205- if len(payload)-off < 7 {
206- return nil, errBadRecord
71+ keyLen, next, ok := decodeLength(payload, off)
72+ if !ok {
73+ return nil, ErrCorrupt
20774 }
208- op := payload[off]
209- off++
210- if op != opSet && op != opDel {
211- return nil, errBadRecord
75+ off = next
76+ if keyLen > uint64(len(payload)-off) {
77+ return nil, ErrCorrupt
21278 }
213- keyLen := int(binary.BigEndian.Uint16(payload[off : off+2]))
214- off += 2
215- if keyLen > len(payload)-off-4 {
216- return nil, errBadRecord
79+ key := string(payload[off : off+int(keyLen)])
80+ off += int(keyLen)
81+ valueLen, next, ok := decodeLength(payload, off)
82+ if !ok {
83+ return nil, ErrCorrupt
21784 }
218- key := append([]byte(nil), payload[off:off+keyLen]...)
219- off += keyLen
220- valueLen := uint64(binary.BigEndian.Uint32(payload[off : off+4]))
221- off += 4
85+ off = next
22286 if valueLen > uint64(len(payload)-off) {
223- return nil, errBadRecord
87+ return nil, ErrCorrupt
22488 }
225- if op == opDel && valueLen != 0 {
226- return nil, errBadRecord
89+ if _, duplicate := data[key]; duplicate {
90+ return nil, ErrCorrupt
22791 }
228- value := append([]byte(nil), payload[off:off+int(valueLen)]...)
92+ data[key] = append([]byte(nil), payload[off:off+int(valueLen)]...)
22993 off += int(valueLen)
230- if _, exists := seen[string(key)]; exists {
231- return nil, errBadRecord
232- }
233- seen[string(key)] = struct{}{}
234- records = append(records, record{op: op, key: key, value: value})
23594 }
23695 if off != len(payload) {
237- return nil, errBadRecord
96+ return nil, ErrCorrupt
97+ }
98+ return data, nil
99+}
100+
101+func decodeLength(payload []byte, off int) (uint64, int, bool) {
102+ if off < 0 || len(payload)-off < 4 {
103+ return 0, off, false
104+ }
105+ length := uint64(binary.BigEndian.Uint32(payload[off : off+4]))
106+ if length > uint64(math.MaxInt) {
107+ return 0, off, false
238108 }
239- return records, nil
109+ return length, off + 4, true
240110 }
@@ -5,236 +5,106 @@ import (
5 "errors"5 "errors"
6 "hash/crc32"6 "hash/crc32"
7 "math"7 "math"
8+ "sort"
8 )9 )
9 10
10-const (11+var ErrCorrupt = errors.New("kv: corrupt store file")
11- opSet byte = iota
12- opDel
13- opBatch
14-
15- // Legacy record framing (files with no file header): a 4-byte big-endian
16- // payload length followed by CRC32(payload). The length itself is
17- // unprotected, so a bit flip in it is indistinguishable from a torn tail.
18- // These files are replayed with the original semantics and upgraded in
19- // place by Open; nothing ever writes this framing again.
20- legacyHeaderLen = 8
21-
22- // Current framing. Every file starts with fileMagic, a uint16 format
23- // version, and CRC32 over that six-byte identity. Every record carries a
24- // self-verifying header:
25- //
26- // [0:4] payload length, big endian
27- // [4:8] CRC32(payload)
28- // [8:12] CRC32(header[0:8])
29- //
30- // The header checksum is what makes the length trustworthy: a corrupt
31- // length now fails closed instead of masquerading as a torn tail and
32- // silently truncating every record that follows it.
33- recordHeaderLen = 12
34- fileMagicLen = 4
35- fileHeaderV1Len = fileMagicLen + 2
36- fileHeaderBodyLen = fileHeaderV1Len
37- fileHeaderLen = fileHeaderBodyLen + 4
38- formatVersionV1 = 1
39- formatVersion = 2
40-
41- // maxPayloadLen bounds any allocation driven by a length field. Catalogue
42- // records and batches are orders of magnitude smaller than this.
43- maxPayloadLen = 64 << 20
44-
45- payloadPrefixLen = 3
46- batchEntryPrefixLen = 7
47- compactThreshold = 64
48- compactWasteRatio = 0.5
49- maxKeyLen = 1<<16 - 1
50-)
51 12
52-// fileMagic cannot be mistaken for a legacy record header: read as a legacy13+var fileMagic = [4]byte{'V', 'E', 'V', 'K'}
53-// big-endian length it is ~1.4 GiB, far beyond maxPayloadLen.
54-var fileMagic = [fileMagicLen]byte{'V', 'E', 'V', 'K'}
55 14
56-var (15+const (
57- errBadRecord = errors.New("bad record")16+ fileVersion uint16 = 3
58- ErrCorruptWAL = errors.New("kv: corrupt WAL")17+ filePrefixLen = 4 + 2 + 4
18+ entryPrefixLen = 4 + 4
19+ crcLen = 4
59 )20 )
60 21
61-type record struct {22+// encodeFile writes magic | version | count | (keyLen key valLen val)* | crc32.
62- op byte23+func encodeFile(data map[string][]byte) []byte {
63- key []byte24+ keys := make([]string, 0, len(data))
64- value []byte25+ for key := range data {
65-}26+ keys = append(keys, key)
66-27+ }
67-// BatchChange is one key mutation contained in a single atomic WAL record.28+ sort.Strings(keys)
68-type BatchChange struct {29+
69- Key []byte30+ size := filePrefixLen + crcLen
70- Value []byte31+ for _, key := range keys {
71- Delete bool32+ size += entryPrefixLen + len(key) + len(data[key])
72-}33+ }
73-34+ buf := make([]byte, 0, size)
74-func encodeRecord(op byte, key, value []byte) ([]byte, error) {35+ buf = append(buf, fileMagic[:]...)
75- if len(key) > maxKeyLen {36+ buf = binary.BigEndian.AppendUint16(buf, fileVersion)
76- return nil, errors.New("key too large")37+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(keys)))
77- }38+ for _, key := range keys {
78- if op != opSet && op != opDel {39+ value := data[key]
79- return nil, errBadRecord40+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(key)))
80- }41+ buf = append(buf, key...)
81- payloadLen := payloadPrefixLen + len(key)42+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(value)))
82- if op == opSet {43+ buf = append(buf, value...)
83- payloadLen += len(value)44+ }
84- }45+ return appendCRC(buf)
85- if payloadLen > maxPayloadLen {
86- return nil, errors.New("payload too large")
87- }
88- payload := make([]byte, payloadLen)
89- payload[0] = op
90- binary.BigEndian.PutUint16(payload[1:3], uint16(len(key)))
91- copy(payload[3:], key)
92- if op == opSet {
93- copy(payload[3+len(key):], value)
94- }
95- return framePayload(payload), nil
96-}
97-
98-func encodeBatch(changes []BatchChange) ([]byte, error) {
99- if len(changes) == 0 {
100- return nil, errors.New("kv: empty batch")
101- }
102- if uint64(len(changes)) > math.MaxUint32 {
103- return nil, errors.New("kv: too many batch changes")
104- }
105- seen := make(map[string]struct{}, len(changes))
106- payloadLen := 1 + 4
107- for _, change := range changes {
108- if len(change.Key) > maxKeyLen {
109- return nil, errors.New("key too large")
110- }
111- if _, exists := seen[string(change.Key)]; exists {
112- return nil, errors.New("kv: duplicate batch key")
113- }
114- seen[string(change.Key)] = struct{}{}
115- valueLen := len(change.Value)
116- if change.Delete {
117- if valueLen != 0 {
118- return nil, errors.New("kv: delete batch change has value")
119- }
120- }
121- payloadLen += 1 + 2 + len(change.Key) + 4 + valueLen
122- if payloadLen > maxPayloadLen {
123- return nil, errors.New("payload too large")
124- }
125- }
126- payload := make([]byte, payloadLen)
127- payload[0] = opBatch
128- binary.BigEndian.PutUint32(payload[1:5], uint32(len(changes)))
129- off := 5
130- for _, change := range changes {
131- payload[off] = opSet
132- if change.Delete {
133- payload[off] = opDel
134- }
135- off++
136- binary.BigEndian.PutUint16(payload[off:off+2], uint16(len(change.Key)))
137- off += 2
138- copy(payload[off:], change.Key)
139- off += len(change.Key)
140- binary.BigEndian.PutUint32(payload[off:off+4], uint32(len(change.Value)))
141- off += 4
142- copy(payload[off:], change.Value)
143- off += len(change.Value)
144- }
145- return framePayload(payload), nil
146-}
147-
148-// fileHeader is the fixed prefix of every file written in the current format.
149-func fileHeader() []byte {
150- buf := make([]byte, fileHeaderLen)
151- copy(buf, fileMagic[:])
152- binary.BigEndian.PutUint16(buf[fileMagicLen:fileHeaderBodyLen], formatVersion)
153- binary.BigEndian.PutUint32(buf[fileHeaderBodyLen:], crc32.ChecksumIEEE(buf[:fileHeaderBodyLen]))
154- return buf
155 }46 }
156 47
157-func framePayload(payload []byte) []byte {48+func appendCRC(payload []byte) []byte {
158- buf := make([]byte, recordHeaderLen+len(payload))49+ return binary.BigEndian.AppendUint32(payload, crc32.ChecksumIEEE(payload))
159- binary.BigEndian.PutUint32(buf[0:4], uint32(len(payload)))
160- binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload))
161- binary.BigEndian.PutUint32(buf[8:recordHeaderLen], crc32.ChecksumIEEE(buf[0:8]))
162- copy(buf[recordHeaderLen:], payload)
163- return buf
164 }50 }
165 51
166-func decodePayload(payload []byte) (record, error) {52+// decodeFile validates the single trailing CRC and rejects every malformed
167- if len(payload) < payloadPrefixLen {53+// existing file. Callers must not replace or truncate a file that fails here.
168- return record{}, errBadRecord54+func decodeFile(raw []byte) (map[string][]byte, error) {
169- }55+ if len(raw) < filePrefixLen+crcLen {
170- op := payload[0]56+ return nil, ErrCorrupt
171- if op != opSet && op != opDel {
172- return record{}, errBadRecord
173 }57 }
174- keyLen := int(binary.BigEndian.Uint16(payload[1:3]))58+ payload := raw[:len(raw)-crcLen]
175- if len(payload) < payloadPrefixLen+keyLen {59+ wantCRC := binary.BigEndian.Uint32(raw[len(raw)-crcLen:])
176- return record{}, errBadRecord60+ if crc32.ChecksumIEEE(payload) != wantCRC {
61+ return nil, ErrCorrupt
177 }62 }
178- if op == opDel && len(payload) != payloadPrefixLen+keyLen {63+ if string(payload[:4]) != string(fileMagic[:]) || binary.BigEndian.Uint16(payload[4:6]) != fileVersion {
179- return record{}, errBadRecord64+ return nil, ErrCorrupt
180 }65 }
181- key := append([]byte(nil), payload[payloadPrefixLen:payloadPrefixLen+keyLen]...)
182- var value []byte
183- if op == opSet {
184- value = append([]byte(nil), payload[payloadPrefixLen+keyLen:]...)
185- }
186- return record{op: op, key: key, value: value}, nil
187-}
188 66
189-func decodeBatch(payload []byte) ([]record, error) {67+ count := binary.BigEndian.Uint32(payload[6:10])
190- if len(payload) < 5 || payload[0] != opBatch {68+ off := filePrefixLen
191- return nil, errBadRecord69+ data := make(map[string][]byte)
192- }
193- count := binary.BigEndian.Uint32(payload[1:5])
194- if count == 0 {
195- return nil, errBadRecord
196- }
197- off := 5
198- maxCount := uint64(len(payload)-off) / batchEntryPrefixLen
199- if uint64(count) > maxCount {
200- return nil, errBadRecord
201- }
202- records := make([]record, 0, int(count))
203- seen := make(map[string]struct{}, int(count))
204 for range count {70 for range count {
205- if len(payload)-off < 7 {71+ keyLen, next, ok := decodeLength(payload, off)
206- return nil, errBadRecord72+ if !ok {
73+ return nil, ErrCorrupt
207 }74 }
208- op := payload[off]75+ off = next
209- off++76+ if keyLen > uint64(len(payload)-off) {
210- if op != opSet && op != opDel {77+ return nil, ErrCorrupt
211- return nil, errBadRecord
212 }78 }
213- keyLen := int(binary.BigEndian.Uint16(payload[off : off+2]))79+ key := string(payload[off : off+int(keyLen)])
214- off += 280+ off += int(keyLen)
215- if keyLen > len(payload)-off-4 {81+ valueLen, next, ok := decodeLength(payload, off)
216- return nil, errBadRecord82+ if !ok {
83+ return nil, ErrCorrupt
217 }84 }
218- key := append([]byte(nil), payload[off:off+keyLen]...)85+ off = next
219- off += keyLen
220- valueLen := uint64(binary.BigEndian.Uint32(payload[off : off+4]))
221- off += 4
222 if valueLen > uint64(len(payload)-off) {86 if valueLen > uint64(len(payload)-off) {
223- return nil, errBadRecord87+ return nil, ErrCorrupt
224 }88 }
225- if op == opDel && valueLen != 0 {89+ if _, duplicate := data[key]; duplicate {
226- return nil, errBadRecord90+ return nil, ErrCorrupt
227 }91 }
228- value := append([]byte(nil), payload[off:off+int(valueLen)]...)92+ data[key] = append([]byte(nil), payload[off:off+int(valueLen)]...)
229 off += int(valueLen)93 off += int(valueLen)
230- if _, exists := seen[string(key)]; exists {
231- return nil, errBadRecord
232- }
233- seen[string(key)] = struct{}{}
234- records = append(records, record{op: op, key: key, value: value})
235 }94 }
236 if off != len(payload) {95 if off != len(payload) {
237- return nil, errBadRecord96+ return nil, ErrCorrupt
97+ }
98+ return data, nil
99+}
100+
101+func decodeLength(payload []byte, off int) (uint64, int, bool) {
102+ if off < 0 || len(payload)-off < 4 {
103+ return 0, off, false
104+ }
105+ length := uint64(binary.BigEndian.Uint32(payload[off : off+4]))
106+ if length > uint64(math.MaxInt) {
107+ return 0, off, false
238 }108 }
239- return records, nil109+ return length, off + 4, true
240 }110 }
M pkg/kv/kv.go +96 -665
@@ -1,11 +1,7 @@
11 package kv
22
33 import (
4- "bytes"
5- "encoding/binary"
64 "errors"
7- "fmt"
8- "hash/crc32"
95 "io"
106 "os"
117 "path/filepath"
@@ -16,99 +12,20 @@ import (
1612 "github.com/bnema/vev/pkg/safedir"
1713 )
1814
19-// ReplayResult describes a strictly validated WAL prefix. LastGood is an
20-// absolute file offset, so truncating a torn tail to it preserves the file
21-// header of a current-format file.
22-type ReplayResult struct {
23- Data map[string][]byte
24- EntrySize map[string]int64
25- Total int64
26- Live int64
27- LastGood int64
28- TornTail bool
29- Format walFormat
30-}
31-
32-// walFormat is the on-disk framing a file was written with.
33-type walFormat uint8
34-
35-const (
36- // formatLegacy is a file written before the record header carried its own
37- // checksum. It is read with the original semantics and upgraded at Open.
38- formatLegacy walFormat = iota
39- // formatCurrentV1 is the released VEVK v1 framing with its six-byte file
40- // header. Its record headers are identical to the current format.
41- formatCurrentV1
42- formatCurrent
43-)
44-
45-func (f walFormat) headerLen() int64 {
46- if f != formatLegacy {
47- return recordHeaderLen
48- }
49- return legacyHeaderLen
50-}
51-
52-func (f walFormat) fileHeaderLen() int64 {
53- switch f {
54- case formatCurrentV1:
55- return fileHeaderV1Len
56- case formatCurrent:
57- return fileHeaderLen
58- default:
59- return 0
60- }
61-}
62-
63-type headerStatus uint8
64-
65-const (
66- headerOK headerStatus = iota
67- headerEOF
68- headerTorn
69-)
70-
71-type fsHooks struct {
72- Open func(string, int, os.FileMode) (*os.File, error)
73- Write func(*os.File, []byte) (int, error)
74- Sync func(*os.File) error
75- Close func(*os.File) error
76- Remove func(string) error
77- Rename func(string, string) error
78- SyncDir func(string) error
79- Fault func(string) error
80-}
81-
82-func defaultFSHooks() fsHooks {
83- return fsHooks{
84- Open: os.OpenFile,
85- Write: func(f *os.File, p []byte) (int, error) { return f.Write(p) },
86- Sync: func(f *os.File) error { return f.Sync() },
87- Close: func(f *os.File) error { return f.Close() },
88- Remove: os.Remove,
89- Rename: os.Rename,
90- SyncDir: syncDir,
91- }
92-}
93-
94-// Store is an append-only WAL-backed key/value store.
15+// Store is an in-memory key/value map persisted by whole-file atomic rewrite.
16+// Set and Delete mutate memory only; Sync is the durability barrier.
9517 type Store struct {
96- mu sync.Mutex
97- path string
98- file *os.File
99- lockFile *os.File
100- data map[string][]byte
101- entrySize map[string]int64
102- total int64
103- live int64
104- closed bool
105- hooks fsHooks
18+ mu sync.Mutex
19+ path string
20+ lockFile *os.File
21+ data map[string][]byte
22+ dirty bool
23+ closed bool
10624 }
10725
108-// Open opens or creates a store, strictly recovers compaction, and truncates only a torn tail.
109-func Open(path string) (*Store, error) { return openWithHooks(path, defaultFSHooks()) }
110-
111-func openWithHooks(path string, hooks fsHooks) (*Store, error) {
26+// Open reads path, or starts empty when it does not exist. Stray .tmp files
27+// are ignored. Any invalid existing main file fails closed and is untouched.
28+func Open(path string) (*Store, error) {
11229 if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
11330 return nil, err
11431 }
@@ -116,654 +33,168 @@ func openWithHooks(path string, hooks fsHooks) (*Store, error) {
11633 if err != nil {
11734 return nil, err
11835 }
119- cleanupLock := func(err error) (*Store, error) { _ = releaseLock(lockFile); return nil, err }
120- if err := recoverCompaction(path, hooks); err != nil {
121- return cleanupLock(err)
122- }
123- f, err := hooks.Open(path, os.O_RDWR|os.O_CREATE, 0o600)
124- if err != nil {
125- return cleanupLock(err)
126- }
127- result, err := replayFile(f)
128- if err != nil {
129- _ = f.Close()
130- return cleanupLock(err)
131- }
132- if result.TornTail {
133- if err := f.Truncate(result.LastGood); err != nil {
134- _ = f.Close()
135- return cleanupLock(err)
136- }
137- }
138- if _, err := f.Seek(0, io.SeekEnd); err != nil {
139- _ = f.Close()
140- return cleanupLock(err)
141- }
142- s := &Store{path: path, file: f, lockFile: lockFile, data: result.Data, entrySize: result.EntrySize, total: result.Total, live: result.Live, hooks: hooks}
143- // A legacy file has already replayed with its original semantics, so the
144- // data is exactly what the previous release would have loaded. Rewriting it
145- // through the compaction protocol upgrades the framing without a second
146- // rewrite path: every crash state it can leave behind is one recoverCompaction
147- // already resolves, so a restart sees either the whole legacy file or the
148- // whole upgraded one. A failed compaction poisons the store, so there is no
149- // safe way to continue in legacy mode; Open fails closed and the next start
150- // retries from whichever complete file recovery selects.
151- if result.Format != formatCurrent || s.shouldCompact() {
152- if err := s.compactLocked(); err != nil {
153- return nil, err
154- }
36+ fail := func(err error) (*Store, error) {
37+ return nil, errors.Join(err, releaseLock(lockFile))
15538 }
156- return s, nil
157-}
15839
159-// Replay reads path without mutating it and returns only strictly valid state.
160-func Replay(path string) (map[string][]byte, error) {
161- f, err := os.Open(path)
40+ raw, err := os.ReadFile(path)
16241 if errors.Is(err, os.ErrNotExist) {
163- return map[string][]byte{}, nil
42+ store := &Store{path: path, lockFile: lockFile, data: make(map[string][]byte), dirty: true}
43+ if err := store.syncLocked(); err != nil {
44+ return fail(err)
45+ }
46+ return store, nil
16447 }
16548 if err != nil {
166- return nil, err
49+ return fail(err)
16750 }
168- defer func() { _ = f.Close() }()
169- result, err := replayFile(f)
51+ data, err := decodeFile(raw)
17052 if err != nil {
171- return nil, err
53+ return fail(err)
17254 }
173- return result.Data, nil
55+ return &Store{path: path, lockFile: lockFile, data: data}, nil
17456 }
17557
17658 func (s *Store) Get(key []byte) ([]byte, bool) {
17759 s.mu.Lock()
17860 defer s.mu.Unlock()
179- v, ok := s.data[string(key)]
180- return append([]byte(nil), v...), ok
181-}
182-func (s *Store) Set(key, val []byte) error {
183- return s.appendEncoded(func() ([]byte, []record, error) {
184- buf, err := encodeRecord(opSet, key, val)
185- return buf, []record{{op: opSet, key: append([]byte(nil), key...), value: append([]byte(nil), val...)}}, err
186- })
187-}
188-func (s *Store) Delete(key []byte) error {
189- return s.appendEncoded(func() ([]byte, []record, error) {
190- buf, err := encodeRecord(opDel, key, nil)
191- return buf, []record{{op: opDel, key: append([]byte(nil), key...)}}, err
192- })
61+ value, ok := s.data[string(key)]
62+ return append([]byte(nil), value...), ok
19363 }
19464
195-// Batch appends all changes as one WAL record and applies them only after a complete write.
196-func (s *Store) Batch(changes []BatchChange) error {
197- return s.appendEncoded(func() ([]byte, []record, error) {
198- buf, err := encodeBatch(changes)
199- if err != nil {
200- return nil, nil, err
201- }
202- recs := make([]record, len(changes))
203- for i, change := range changes {
204- recs[i] = record{op: opSet, key: append([]byte(nil), change.Key...), value: append([]byte(nil), change.Value...)}
205- if change.Delete {
206- recs[i].op = opDel
207- }
208- }
209- return buf, recs, nil
210- })
211-}
212-
213-func (s *Store) Range(fn func(k, v []byte) bool) {
214- s.mu.Lock()
215- keys := make([]string, 0, len(s.data))
216- for k := range s.data {
217- keys = append(keys, k)
218- }
219- sort.Strings(keys)
220- pairs := make([]record, 0, len(keys))
221- for _, k := range keys {
222- pairs = append(pairs, record{key: []byte(k), value: append([]byte(nil), s.data[k]...)})
223- }
224- s.mu.Unlock()
225- for _, pair := range pairs {
226- if !fn(pair.key, pair.value) {
227- return
228- }
229- }
230-}
231-func (s *Store) Sync() error {
65+func (s *Store) Set(key, value []byte) error {
23266 s.mu.Lock()
23367 defer s.mu.Unlock()
23468 if s.closed {
23569 return os.ErrClosed
23670 }
237- return s.file.Sync()
71+ s.data[string(key)] = append([]byte(nil), value...)
72+ s.dirty = true
73+ return nil
23874 }
239-func (s *Store) Close() error {
75+
76+func (s *Store) Delete(key []byte) error {
24077 s.mu.Lock()
24178 defer s.mu.Unlock()
24279 if s.closed {
243- return nil
80+ return os.ErrClosed
24481 }
245- err := errors.Join(s.file.Sync(), s.file.Close(), releaseLock(s.lockFile))
246- s.closed = true
247- return err
82+ if _, exists := s.data[string(key)]; exists {
83+ delete(s.data, string(key))
84+ s.dirty = true
85+ }
86+ return nil
24887 }
24988
250-func acquireLock(path string) (*os.File, error) {
251- f, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
252- if err != nil {
253- return nil, err
89+func (s *Store) Range(fn func(k, v []byte) bool) {
90+ s.mu.Lock()
91+ keys := make([]string, 0, len(s.data))
92+ for key := range s.data {
93+ keys = append(keys, key)
25494 }
255- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
256- _ = f.Close()
257- return nil, err
95+ sort.Strings(keys)
96+ values := make([][]byte, len(keys))
97+ for i, key := range keys {
98+ values[i] = append([]byte(nil), s.data[key]...)
25899 }
259- return f, nil
260-}
261-func releaseLock(f *os.File) error {
262- if f == nil {
263- return nil
100+ s.mu.Unlock()
101+ for i, key := range keys {
102+ if !fn([]byte(key), values[i]) {
103+ return
104+ }
264105 }
265- return errors.Join(syscall.Flock(int(f.Fd()), syscall.LOCK_UN), f.Close())
266106 }
267107
268-func (s *Store) appendEncoded(encode func() ([]byte, []record, error)) error {
269- buf, records, err := encode()
270- if err != nil {
271- return err
272- }
108+// Sync writes path.tmp, fsyncs it, renames it over path, then fsyncs the
109+// directory. The main pathname therefore contains either the previous whole
110+// map or the next whole map, never a subset of mutations.
111+func (s *Store) Sync() error {
273112 s.mu.Lock()
274113 defer s.mu.Unlock()
275114 if s.closed {
276115 return os.ErrClosed
277116 }
278- pos, err := s.file.Seek(0, io.SeekCurrent)
279- if err != nil {
280- return err
281- }
282- n, writeErr := s.file.Write(buf)
283- if writeErr != nil || n != len(buf) {
284- if writeErr == nil {
285- writeErr = io.ErrShortWrite
286- }
287- if err := errors.Join(s.file.Truncate(pos), seekTo(s.file, pos)); err != nil {
288- s.closed = true
289- return fmt.Errorf("write failed (%w) and WAL repair failed: %w", writeErr, err)
290- }
291- return writeErr
292- }
293- applyRecords(s.data, s.entrySize, &s.live, records, int64(len(buf)))
294- s.total += int64(len(buf))
295- if s.shouldCompact() {
296- return s.compactLocked()
297- }
298- return nil
299-}
300-func seekTo(f *os.File, pos int64) error { _, err := f.Seek(pos, io.SeekStart); return err }
301-func (s *Store) shouldCompact() bool {
302- return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
117+ return s.syncLocked()
303118 }
304119
305-func (s *Store) compactLocked() (retErr error) {
306- defer func() {
307- if retErr != nil {
308- retErr = errors.Join(retErr, s.poisonLocked())
309- }
310- }()
311- next, prev := s.path+".next", s.path+".prev"
312- if err := injectFault(s.hooks, "before-write-next"); err != nil {
313- return err
314- }
315- f, err := s.hooks.Open(next, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
316- if err != nil {
317- return err
318- }
319- // Every replacement is written in the current format, which is also how a
320- // legacy file is upgraded.
321- header := fileHeader()
322- if n, err := s.hooks.Write(f, header); err != nil || n != len(header) {
323- _ = s.hooks.Close(f)
324- if err == nil {
325- err = io.ErrShortWrite
326- }
327- return err
328- }
329- keys := make([]string, 0, len(s.data))
330- for k := range s.data {
331- keys = append(keys, k)
332- }
333- sort.Strings(keys)
334- for _, key := range keys {
335- buf, err := encodeRecord(opSet, []byte(key), s.data[key])
336- if err != nil {
337- _ = s.hooks.Close(f)
338- return err
339- }
340- if n, err := s.hooks.Write(f, buf); err != nil || n != len(buf) {
341- _ = s.hooks.Close(f)
342- if err == nil {
343- err = io.ErrShortWrite
344- }
345- return err
346- }
347- }
348- if err := injectFault(s.hooks, "after-write-next"); err != nil {
349- _ = s.hooks.Close(f)
350- return err
351- }
352- if err := injectFault(s.hooks, "before-sync-next"); err != nil {
353- _ = s.hooks.Close(f)
354- return err
355- }
356- if err := s.hooks.Sync(f); err != nil {
357- _ = s.hooks.Close(f)
358- return err
359- }
360- if err := injectFault(s.hooks, "after-sync-next"); err != nil {
361- _ = s.hooks.Close(f)
362- return err
363- }
364- if err := s.hooks.Close(f); err != nil {
365- return err
366- }
367- if err := validatePath(next); err != nil {
368- return err
369- }
370- dir := filepath.Dir(s.path)
371- if err := s.hooks.SyncDir(dir); err != nil {
372- return err
373- }
374- if err := injectFault(s.hooks, "after-sync-dir-next"); err != nil {
375- return err
376- }
377- if err := removeIfExists(s.hooks, prev); err != nil {
378- return err
379- }
380- if err := injectFault(s.hooks, "after-remove-stale-prev"); err != nil {
381- return err
382- }
383- if err := s.hooks.Rename(s.path, prev); err != nil {
384- return err
385- }
386- if err := injectFault(s.hooks, "after-rename-current-prev"); err != nil {
387- return err
388- }
389- if err := s.hooks.SyncDir(dir); err != nil {
390- return err
391- }
392- if err := injectFault(s.hooks, "after-sync-dir-prev"); err != nil {
393- return err
394- }
395- if err := s.hooks.Rename(next, s.path); err != nil {
396- return err
397- }
398- if err := injectFault(s.hooks, "after-rename-next-current"); err != nil {
399- return err
400- }
401- if err := s.hooks.SyncDir(dir); err != nil {
402- return err
403- }
404- if err := injectFault(s.hooks, "after-sync-dir-current"); err != nil {
405- return err
406- }
407- if err := injectFault(s.hooks, "before-reopen-current"); err != nil {
408- return err
120+func (s *Store) syncLocked() error {
121+ if !s.dirty {
122+ return nil
409123 }
410- current, err := s.hooks.Open(s.path, os.O_RDWR, 0o600)
124+ data := encodeFile(s.data)
125+ tmp := s.path + ".tmp"
126+ file, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
411127 if err != nil {
412128 return err
413129 }
414- if err := injectFault(s.hooks, "after-reopen-current"); err != nil {
415- _ = s.hooks.Close(current)
416- return err
417- }
418- result, err := replayFile(current)
419- if err != nil || result.TornTail || result.Format != formatCurrent {
420- _ = s.hooks.Close(current)
421- if err == nil {
422- err = ErrCorruptWAL
423- }
424- return err
425- }
426- if _, err := current.Seek(0, io.SeekEnd); err != nil {
427- _ = s.hooks.Close(current)
428- return err
130+ if err := writeAll(file, data); err != nil {
131+ return errors.Join(err, file.Close())
429132 }
430- old := s.file
431- s.file = current
432- s.data, s.entrySize, s.total, s.live = result.Data, result.EntrySize, result.Total, result.Live
433- if err := s.hooks.Close(old); err != nil {
434- return err
133+ if err := file.Sync(); err != nil {
134+ return errors.Join(err, file.Close())
435135 }
436- if err := removeIfExists(s.hooks, prev); err != nil {
136+ if err := file.Close(); err != nil {
437137 return err
438138 }
439- if err := injectFault(s.hooks, "after-remove-prev"); err != nil {
139+ if err := os.Rename(tmp, s.path); err != nil {
440140 return err
441141 }
442- if err := s.hooks.SyncDir(dir); err != nil {
142+ if err := syncDir(filepath.Dir(s.path)); err != nil {
443143 return err
444144 }
445- return injectFault(s.hooks, "after-final-sync-dir")
145+ s.dirty = false
146+ return nil
446147 }
447148
448-// poisonLocked permanently disables a store whose compaction did not complete.
449-// The caller holds s.mu, so no later append can retain a descriptor for a
450-// transitional pathname.
451-func (s *Store) poisonLocked() error {
149+// Close syncs buffered mutations before releasing the process lock.
150+func (s *Store) Close() error {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
452153 if s.closed {
453154 return nil
454155 }
156+ err := s.syncLocked()
455157 s.closed = true
456- file, lockFile := s.file, s.lockFile
457- s.file, s.lockFile = nil, nil
458- var fileErr error
459- if file != nil {
460- fileErr = s.hooks.Close(file)
461- }
462- return errors.Join(fileErr, releaseLock(lockFile))
158+ return errors.Join(err, releaseLock(s.lockFile))
463159 }
464160
465-// replayFile validates a WAL prefix in whichever framing the file uses. A torn
466-// tail is the only truncatable outcome; every other inconsistency is corruption
467-// and fails closed, so a damaged record can never be silently dropped along
468-// with everything written after it.
469-func replayFile(f *os.File) (ReplayResult, error) {
470- info, err := f.Stat()
471- if err != nil {
472- return ReplayResult{}, err
473- }
474- if _, err := f.Seek(0, io.SeekStart); err != nil {
475- return ReplayResult{}, err
476- }
477- fileSize := info.Size()
478- format, err := readFileFormat(f, fileSize)
479- if err != nil {
480- return ReplayResult{}, err
481- }
482- result := ReplayResult{Data: make(map[string][]byte), EntrySize: make(map[string]int64), Format: format, LastGood: format.fileHeaderLen()}
483- for {
484- header, status, err := readRecordHeader(f, format, fileSize, result.LastGood)
485- if err != nil {
486- return ReplayResult{}, err
487- }
488- if status == headerEOF {
489- return result, nil
490- }
491- if status == headerTorn {
492- result.TornTail = true
493- return result, nil
494- }
495- payload := make([]byte, int(header.payloadLen))
496- if _, err := io.ReadFull(f, payload); errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
497- result.TornTail = true
498- return result, nil
499- } else if err != nil {
500- return ReplayResult{}, err
501- }
502- if crc32.ChecksumIEEE(payload) != header.payloadCRC {
503- return ReplayResult{}, fmt.Errorf("%w: CRC mismatch at offset %d", ErrCorruptWAL, result.LastGood)
504- }
505- var records []record
506- if len(payload) > 0 && payload[0] == opBatch {
507- records, err = decodeBatch(payload)
508- } else {
509- var rec record
510- rec, err = decodePayload(payload)
511- records = []record{rec}
512- }
161+func writeAll(file *os.File, data []byte) error {
162+ for len(data) > 0 {
163+ n, err := file.Write(data)
513164 if err != nil {
514- return ReplayResult{}, fmt.Errorf("%w: invalid record at offset %d: %w", ErrCorruptWAL, result.LastGood, err)
515- }
516- size := format.headerLen() + int64(len(payload))
517- applyRecords(result.Data, result.EntrySize, &result.Live, records, size)
518- result.Total += size
519- result.LastGood += size
520- }
521-}
522-
523-// readFileFormat consumes the file header when one is present and leaves the
524-// reader positioned at the first record either way.
525-func readFileFormat(f io.ReadSeeker, fileSize int64) (walFormat, error) {
526- if fileSize == 0 {
527- return formatLegacy, nil
528- }
529- probeLen := min(fileSize, int64(fileHeaderLen))
530- probe := make([]byte, probeLen)
531- if _, err := io.ReadFull(f, probe); err != nil {
532- return formatLegacy, err
533- }
534- if fileSize < fileMagicLen {
535- if bytes.Equal(probe, fileMagic[:len(probe)]) {
536- return formatLegacy, fmt.Errorf("%w: incomplete VEVK header", ErrCorruptWAL)
537- }
538- if _, err := f.Seek(0, io.SeekStart); err != nil {
539- return formatLegacy, err
540- }
541- return formatLegacy, nil
542- }
543- if !bytes.Equal(probe[:fileMagicLen], fileMagic[:]) {
544- // A complete v2 identity checksum authenticates the intended magic as
545- // well as the version. Detect it before legacy replay so damage to the
546- // first magic byte cannot masquerade as a small legacy length and be
547- // truncated or compacted away.
548- if len(probe) == fileHeaderLen &&
549- binary.BigEndian.Uint16(probe[fileMagicLen:fileHeaderBodyLen]) == formatVersion &&
550- binary.BigEndian.Uint32(probe[fileHeaderBodyLen:]) == binary.BigEndian.Uint32(fileHeader()[fileHeaderBodyLen:]) {
551- return formatLegacy, fmt.Errorf("%w: damaged VEVK v2 magic", ErrCorruptWAL)
552- }
553- if _, err := f.Seek(0, io.SeekStart); err != nil {
554- return formatLegacy, err
555- }
556- return formatLegacy, nil
557- }
558- if fileSize < fileHeaderV1Len {
559- return formatLegacy, fmt.Errorf("%w: incomplete VEVK header", ErrCorruptWAL)
560- }
561- prefix := probe[:fileHeaderV1Len]
562- if _, err := f.Seek(fileHeaderV1Len, io.SeekStart); err != nil {
563- return formatLegacy, err
564- }
565- switch version := binary.BigEndian.Uint16(prefix[fileMagicLen:]); version {
566- case formatVersionV1:
567- return formatCurrentV1, nil
568- case formatVersion:
569- checksum := make([]byte, fileHeaderLen-fileHeaderV1Len)
570- if _, err := io.ReadFull(f, checksum); err != nil {
571- return formatLegacy, fmt.Errorf("%w: incomplete VEVK v2 header", ErrCorruptWAL)
572- }
573- if crc32.ChecksumIEEE(prefix) != binary.BigEndian.Uint32(checksum) {
574- return formatLegacy, fmt.Errorf("%w: file header checksum mismatch", ErrCorruptWAL)
575- }
576- return formatCurrent, nil
577- default:
578- return formatLegacy, fmt.Errorf("%w: unsupported format version %d", ErrCorruptWAL, version)
579- }
580-}
581-
582-type recordHeader struct {
583- payloadLen uint32
584- payloadCRC uint32
585-}
586-
587-// readRecordHeader implements the replay decision table. In the current format
588-// the header checksum is verified before the length is trusted for anything:
589-// an unverifiable header is corruption, and only a header that checks out may
590-// declare the record short (a torn tail).
591-func readRecordHeader(f io.Reader, format walFormat, fileSize, offset int64) (recordHeader, headerStatus, error) {
592- buf := make([]byte, format.headerLen())
593- if _, err := io.ReadFull(f, buf); errors.Is(err, io.EOF) {
594- return recordHeader{}, headerEOF, nil
595- } else if errors.Is(err, io.ErrUnexpectedEOF) {
596- return recordHeader{}, headerTorn, nil
597- } else if err != nil {
598- return recordHeader{}, headerOK, err
599- }
600- header := recordHeader{
601- payloadLen: binary.BigEndian.Uint32(buf[0:4]),
602- payloadCRC: binary.BigEndian.Uint32(buf[4:8]),
603- }
604- if format != formatLegacy {
605- if crc32.ChecksumIEEE(buf[0:8]) != binary.BigEndian.Uint32(buf[8:recordHeaderLen]) {
606- return recordHeader{}, headerOK, fmt.Errorf("%w: record header CRC mismatch at offset %d", ErrCorruptWAL, offset)
607- }
608- // The length is now trustworthy, so an implausible one is corruption
609- // rather than a short tail. Checked before any allocation.
610- }
611- // Every framing is allocation-bounded. In legacy framing an oversized
612- // declaration remains corruption rather than driving an attacker-sized
613- // allocation; ordinary short tails retain their released truncation semantics.
614- if header.payloadLen > maxPayloadLen {
615- return recordHeader{}, headerOK, fmt.Errorf("%w: record length %d exceeds maximum at offset %d", ErrCorruptWAL, header.payloadLen, offset)
616- }
617- if int64(header.payloadLen) > fileSize-offset-format.headerLen() {
618- return recordHeader{}, headerTorn, nil
619- }
620- return header, headerOK, nil
621-}
622-
623-func applyRecords(data map[string][]byte, sizes map[string]int64, live *int64, records []record, size int64) {
624- setCount := int64(0)
625- for _, rec := range records {
626- if rec.op == opSet {
627- setCount++
628- }
629- }
630- setSize, remainder := size, int64(0)
631- if setCount > 1 {
632- setSize, remainder = size/setCount, size%setCount
633- }
634- for _, rec := range records {
635- key := string(rec.key)
636- if old, ok := sizes[key]; ok {
637- *live -= old
638- }
639- if rec.op == opSet {
640- entrySize := setSize
641- if remainder > 0 {
642- entrySize++
643- remainder--
644- }
645- data[key] = append([]byte(nil), rec.value...)
646- sizes[key] = entrySize
647- *live += entrySize
648- } else {
649- delete(data, key)
650- delete(sizes, key)
651- }
652- }
653-}
654-
655-func recoverCompaction(path string, hooks fsHooks) error {
656- currentResult, currentExists, currentErr := candidate(path)
657- nextResult, nextExists, nextErr := candidate(path + ".next")
658- _, prevExists, prevErr := candidate(path + ".prev")
659- if nextErr == nil && nextResult.TornTail {
660- nextErr = ErrCorruptWAL
661- }
662- dir := filepath.Dir(path)
663- if currentExists && currentErr == nil {
664- if currentResult.TornTail && prevExists {
665- // A predecessor proves compaction was in flight. The torn current is
666- // ambiguous, so preserve both candidates and fail closed rather than
667- // truncating current and deleting the last complete predecessor.
668- return fmt.Errorf("%w: torn current with predecessor", ErrCorruptWAL)
669- }
670- if nextExists {
671- if err := hooks.Remove(path + ".next"); err != nil {
672- return err
673- }
674- if err := hooks.SyncDir(dir); err != nil {
675- return err
676- }
677- }
678- if prevExists {
679- if err := hooks.Remove(path + ".prev"); err != nil {
680- return err
681- }
682- if err := hooks.SyncDir(dir); err != nil {
683- return err
684- }
685- }
686- return nil
687- }
688- if currentExists && currentErr != nil {
689- // Corruption does not prove which bytes are disposable. Preserve every
690- // candidate for diagnosis/recovery instead of deleting current or a valid
691- // predecessor and fail closed.
692- return fmt.Errorf("%w: invalid current candidate", ErrCorruptWAL)
693- }
694- if !currentExists && (nextExists || prevExists) {
695- // A complete .next is the newest fully published compaction candidate and
696- // wins even when .prev exists but is corrupt. Otherwise a complete .prev
697- // is the only safe rollback. Invalid candidates are never selected.
698- source := ""
699- if nextExists && nextErr == nil {
700- source = path + ".next"
701- } else if prevExists && prevErr == nil {
702- source = path + ".prev"
703- }
704- if source == "" {
705- return fmt.Errorf("%w: no valid current, successor, or predecessor", ErrCorruptWAL)
706- }
707- if err := hooks.Rename(source, path); err != nil {
708165 return err
709166 }
710- if err := hooks.SyncDir(dir); err != nil {
711- return err
712- }
713- for _, stale := range []string{path + ".prev", path + ".next"} {
714- if stale != source {
715- if err := removeIfExists(hooks, stale); err != nil {
716- return err
717- }
718- }
167+ if n == 0 {
168+ return io.ErrShortWrite
719169 }
720- return hooks.SyncDir(dir)
170+ data = data[n:]
721171 }
722172 return nil
723173 }
724174
725-func candidate(path string) (ReplayResult, bool, error) {
726- f, err := os.Open(path)
727- if errors.Is(err, os.ErrNotExist) {
728- return ReplayResult{}, false, nil
729- }
730- if err != nil {
731- return ReplayResult{}, true, err
732- }
733- defer func() { _ = f.Close() }()
734- result, err := replayFile(f)
735- return result, true, err
736-}
737-func validatePath(path string) error {
738- result, exists, err := candidate(path)
175+func acquireLock(path string) (*os.File, error) {
176+ file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
739177 if err != nil {
740- return err
741- }
742- // A replacement this package just wrote must be complete and in the current
743- // format; anything else means the write did not land as intended.
744- if !exists || result.TornTail || result.Format != formatCurrent {
745- return ErrCorruptWAL
178+ return nil, err
746179 }
747- return nil
748-}
749-func removeIfExists(hooks fsHooks, path string) error {
750- err := hooks.Remove(path)
751- if errors.Is(err, os.ErrNotExist) {
752- return nil
180+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
181+ _ = file.Close()
182+ return nil, err
753183 }
754- return err
184+ return file, nil
755185 }
756-func injectFault(hooks fsHooks, point string) error {
757- if hooks.Fault == nil {
186+
187+func releaseLock(file *os.File) error {
188+ if file == nil {
758189 return nil
759190 }
760- return hooks.Fault(point)
191+ return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
761192 }
762193
763194 func syncDir(dir string) error {
764- f, err := os.Open(dir)
195+ file, err := os.Open(dir)
765196 if err != nil {
766197 return err
767198 }
768- return errors.Join(f.Sync(), f.Close())
199+ return errors.Join(file.Sync(), file.Close())
769200 }
@@ -1,11 +1,7 @@
1 package kv1 package kv
2 2
3 import (3 import (
4- "bytes"
5- "encoding/binary"
6 "errors"4 "errors"
7- "fmt"
8- "hash/crc32"
9 "io"5 "io"
10 "os"6 "os"
11 "path/filepath"7 "path/filepath"
@@ -16,99 +12,20 @@ import (
16 "github.com/bnema/vev/pkg/safedir"12 "github.com/bnema/vev/pkg/safedir"
17 )13 )
18 14
19-// ReplayResult describes a strictly validated WAL prefix. LastGood is an15+// Store is an in-memory key/value map persisted by whole-file atomic rewrite.
20-// absolute file offset, so truncating a torn tail to it preserves the file16+// Set and Delete mutate memory only; Sync is the durability barrier.
21-// header of a current-format file.
22-type ReplayResult struct {
23- Data map[string][]byte
24- EntrySize map[string]int64
25- Total int64
26- Live int64
27- LastGood int64
28- TornTail bool
29- Format walFormat
30-}
31-
32-// walFormat is the on-disk framing a file was written with.
33-type walFormat uint8
34-
35-const (
36- // formatLegacy is a file written before the record header carried its own
37- // checksum. It is read with the original semantics and upgraded at Open.
38- formatLegacy walFormat = iota
39- // formatCurrentV1 is the released VEVK v1 framing with its six-byte file
40- // header. Its record headers are identical to the current format.
41- formatCurrentV1
42- formatCurrent
43-)
44-
45-func (f walFormat) headerLen() int64 {
46- if f != formatLegacy {
47- return recordHeaderLen
48- }
49- return legacyHeaderLen
50-}
51-
52-func (f walFormat) fileHeaderLen() int64 {
53- switch f {
54- case formatCurrentV1:
55- return fileHeaderV1Len
56- case formatCurrent:
57- return fileHeaderLen
58- default:
59- return 0
60- }
61-}
62-
63-type headerStatus uint8
64-
65-const (
66- headerOK headerStatus = iota
67- headerEOF
68- headerTorn
69-)
70-
71-type fsHooks struct {
72- Open func(string, int, os.FileMode) (*os.File, error)
73- Write func(*os.File, []byte) (int, error)
74- Sync func(*os.File) error
75- Close func(*os.File) error
76- Remove func(string) error
77- Rename func(string, string) error
78- SyncDir func(string) error
79- Fault func(string) error
80-}
81-
82-func defaultFSHooks() fsHooks {
83- return fsHooks{
84- Open: os.OpenFile,
85- Write: func(f *os.File, p []byte) (int, error) { return f.Write(p) },
86- Sync: func(f *os.File) error { return f.Sync() },
87- Close: func(f *os.File) error { return f.Close() },
88- Remove: os.Remove,
89- Rename: os.Rename,
90- SyncDir: syncDir,
91- }
92-}
93-
94-// Store is an append-only WAL-backed key/value store.
95 type Store struct {17 type Store struct {
96- mu sync.Mutex18+ mu sync.Mutex
97- path string19+ path string
98- file *os.File20+ lockFile *os.File
99- lockFile *os.File21+ data map[string][]byte
100- data map[string][]byte22+ dirty bool
101- entrySize map[string]int6423+ closed bool
102- total int64
103- live int64
104- closed bool
105- hooks fsHooks
106 }24 }
107 25
108-// Open opens or creates a store, strictly recovers compaction, and truncates only a torn tail.26+// Open reads path, or starts empty when it does not exist. Stray .tmp files
109-func Open(path string) (*Store, error) { return openWithHooks(path, defaultFSHooks()) }27+// are ignored. Any invalid existing main file fails closed and is untouched.
110-28+func Open(path string) (*Store, error) {
111-func openWithHooks(path string, hooks fsHooks) (*Store, error) {
112 if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {29 if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
113 return nil, err30 return nil, err
114 }31 }
@@ -116,654 +33,168 @@ func openWithHooks(path string, hooks fsHooks) (*Store, error) {
116 if err != nil {33 if err != nil {
117 return nil, err34 return nil, err
118 }35 }
119- cleanupLock := func(err error) (*Store, error) { _ = releaseLock(lockFile); return nil, err }36+ fail := func(err error) (*Store, error) {
120- if err := recoverCompaction(path, hooks); err != nil {37+ return nil, errors.Join(err, releaseLock(lockFile))
121- return cleanupLock(err)
122- }
123- f, err := hooks.Open(path, os.O_RDWR|os.O_CREATE, 0o600)
124- if err != nil {
125- return cleanupLock(err)
126- }
127- result, err := replayFile(f)
128- if err != nil {
129- _ = f.Close()
130- return cleanupLock(err)
131- }
132- if result.TornTail {
133- if err := f.Truncate(result.LastGood); err != nil {
134- _ = f.Close()
135- return cleanupLock(err)
136- }
137- }
138- if _, err := f.Seek(0, io.SeekEnd); err != nil {
139- _ = f.Close()
140- return cleanupLock(err)
141- }
142- s := &Store{path: path, file: f, lockFile: lockFile, data: result.Data, entrySize: result.EntrySize, total: result.Total, live: result.Live, hooks: hooks}
143- // A legacy file has already replayed with its original semantics, so the
144- // data is exactly what the previous release would have loaded. Rewriting it
145- // through the compaction protocol upgrades the framing without a second
146- // rewrite path: every crash state it can leave behind is one recoverCompaction
147- // already resolves, so a restart sees either the whole legacy file or the
148- // whole upgraded one. A failed compaction poisons the store, so there is no
149- // safe way to continue in legacy mode; Open fails closed and the next start
150- // retries from whichever complete file recovery selects.
151- if result.Format != formatCurrent || s.shouldCompact() {
152- if err := s.compactLocked(); err != nil {
153- return nil, err
154- }
155 }38 }
156- return s, nil
157-}
158 39
159-// Replay reads path without mutating it and returns only strictly valid state.40+ raw, err := os.ReadFile(path)
160-func Replay(path string) (map[string][]byte, error) {
161- f, err := os.Open(path)
162 if errors.Is(err, os.ErrNotExist) {41 if errors.Is(err, os.ErrNotExist) {
163- return map[string][]byte{}, nil42+ store := &Store{path: path, lockFile: lockFile, data: make(map[string][]byte), dirty: true}
43+ if err := store.syncLocked(); err != nil {
44+ return fail(err)
45+ }
46+ return store, nil
164 }47 }
165 if err != nil {48 if err != nil {
166- return nil, err49+ return fail(err)
167 }50 }
168- defer func() { _ = f.Close() }()51+ data, err := decodeFile(raw)
169- result, err := replayFile(f)
170 if err != nil {52 if err != nil {
171- return nil, err53+ return fail(err)
172 }54 }
173- return result.Data, nil55+ return &Store{path: path, lockFile: lockFile, data: data}, nil
174 }56 }
175 57
176 func (s *Store) Get(key []byte) ([]byte, bool) {58 func (s *Store) Get(key []byte) ([]byte, bool) {
177 s.mu.Lock()59 s.mu.Lock()
178 defer s.mu.Unlock()60 defer s.mu.Unlock()
179- v, ok := s.data[string(key)]61+ value, ok := s.data[string(key)]
180- return append([]byte(nil), v...), ok62+ return append([]byte(nil), value...), ok
181-}
182-func (s *Store) Set(key, val []byte) error {
183- return s.appendEncoded(func() ([]byte, []record, error) {
184- buf, err := encodeRecord(opSet, key, val)
185- return buf, []record{{op: opSet, key: append([]byte(nil), key...), value: append([]byte(nil), val...)}}, err
186- })
187-}
188-func (s *Store) Delete(key []byte) error {
189- return s.appendEncoded(func() ([]byte, []record, error) {
190- buf, err := encodeRecord(opDel, key, nil)
191- return buf, []record{{op: opDel, key: append([]byte(nil), key...)}}, err
192- })
193 }63 }
194 64
195-// Batch appends all changes as one WAL record and applies them only after a complete write.65+func (s *Store) Set(key, value []byte) error {
196-func (s *Store) Batch(changes []BatchChange) error {
197- return s.appendEncoded(func() ([]byte, []record, error) {
198- buf, err := encodeBatch(changes)
199- if err != nil {
200- return nil, nil, err
201- }
202- recs := make([]record, len(changes))
203- for i, change := range changes {
204- recs[i] = record{op: opSet, key: append([]byte(nil), change.Key...), value: append([]byte(nil), change.Value...)}
205- if change.Delete {
206- recs[i].op = opDel
207- }
208- }
209- return buf, recs, nil
210- })
211-}
212-
213-func (s *Store) Range(fn func(k, v []byte) bool) {
214- s.mu.Lock()
215- keys := make([]string, 0, len(s.data))
216- for k := range s.data {
217- keys = append(keys, k)
218- }
219- sort.Strings(keys)
220- pairs := make([]record, 0, len(keys))
221- for _, k := range keys {
222- pairs = append(pairs, record{key: []byte(k), value: append([]byte(nil), s.data[k]...)})
223- }
224- s.mu.Unlock()
225- for _, pair := range pairs {
226- if !fn(pair.key, pair.value) {
227- return
228- }
229- }
230-}
231-func (s *Store) Sync() error {
232 s.mu.Lock()66 s.mu.Lock()
233 defer s.mu.Unlock()67 defer s.mu.Unlock()
234 if s.closed {68 if s.closed {
235 return os.ErrClosed69 return os.ErrClosed
236 }70 }
237- return s.file.Sync()71+ s.data[string(key)] = append([]byte(nil), value...)
72+ s.dirty = true
73+ return nil
238 }74 }
239-func (s *Store) Close() error {75+
76+func (s *Store) Delete(key []byte) error {
240 s.mu.Lock()77 s.mu.Lock()
241 defer s.mu.Unlock()78 defer s.mu.Unlock()
242 if s.closed {79 if s.closed {
243- return nil80+ return os.ErrClosed
244 }81 }
245- err := errors.Join(s.file.Sync(), s.file.Close(), releaseLock(s.lockFile))82+ if _, exists := s.data[string(key)]; exists {
246- s.closed = true83+ delete(s.data, string(key))
247- return err84+ s.dirty = true
85+ }
86+ return nil
248 }87 }
249 88
250-func acquireLock(path string) (*os.File, error) {89+func (s *Store) Range(fn func(k, v []byte) bool) {
251- f, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)90+ s.mu.Lock()
252- if err != nil {91+ keys := make([]string, 0, len(s.data))
253- return nil, err92+ for key := range s.data {
93+ keys = append(keys, key)
254 }94 }
255- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {95+ sort.Strings(keys)
256- _ = f.Close()96+ values := make([][]byte, len(keys))
257- return nil, err97+ for i, key := range keys {
98+ values[i] = append([]byte(nil), s.data[key]...)
258 }99 }
259- return f, nil100+ s.mu.Unlock()
260-}101+ for i, key := range keys {
261-func releaseLock(f *os.File) error {102+ if !fn([]byte(key), values[i]) {
262- if f == nil {103+ return
263- return nil104+ }
264 }105 }
265- return errors.Join(syscall.Flock(int(f.Fd()), syscall.LOCK_UN), f.Close())
266 }106 }
267 107
268-func (s *Store) appendEncoded(encode func() ([]byte, []record, error)) error {108+// Sync writes path.tmp, fsyncs it, renames it over path, then fsyncs the
269- buf, records, err := encode()109+// directory. The main pathname therefore contains either the previous whole
270- if err != nil {110+// map or the next whole map, never a subset of mutations.
271- return err111+func (s *Store) Sync() error {
272- }
273 s.mu.Lock()112 s.mu.Lock()
274 defer s.mu.Unlock()113 defer s.mu.Unlock()
275 if s.closed {114 if s.closed {
276 return os.ErrClosed115 return os.ErrClosed
277 }116 }
278- pos, err := s.file.Seek(0, io.SeekCurrent)117+ return s.syncLocked()
279- if err != nil {
280- return err
281- }
282- n, writeErr := s.file.Write(buf)
283- if writeErr != nil || n != len(buf) {
284- if writeErr == nil {
285- writeErr = io.ErrShortWrite
286- }
287- if err := errors.Join(s.file.Truncate(pos), seekTo(s.file, pos)); err != nil {
288- s.closed = true
289- return fmt.Errorf("write failed (%w) and WAL repair failed: %w", writeErr, err)
290- }
291- return writeErr
292- }
293- applyRecords(s.data, s.entrySize, &s.live, records, int64(len(buf)))
294- s.total += int64(len(buf))
295- if s.shouldCompact() {
296- return s.compactLocked()
297- }
298- return nil
299-}
300-func seekTo(f *os.File, pos int64) error { _, err := f.Seek(pos, io.SeekStart); return err }
301-func (s *Store) shouldCompact() bool {
302- return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
303 }118 }
304 119
305-func (s *Store) compactLocked() (retErr error) {120+func (s *Store) syncLocked() error {
306- defer func() {121+ if !s.dirty {
307- if retErr != nil {122+ return nil
308- retErr = errors.Join(retErr, s.poisonLocked())
309- }
310- }()
311- next, prev := s.path+".next", s.path+".prev"
312- if err := injectFault(s.hooks, "before-write-next"); err != nil {
313- return err
314- }
315- f, err := s.hooks.Open(next, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
316- if err != nil {
317- return err
318- }
319- // Every replacement is written in the current format, which is also how a
320- // legacy file is upgraded.
321- header := fileHeader()
322- if n, err := s.hooks.Write(f, header); err != nil || n != len(header) {
323- _ = s.hooks.Close(f)
324- if err == nil {
325- err = io.ErrShortWrite
326- }
327- return err
328- }
329- keys := make([]string, 0, len(s.data))
330- for k := range s.data {
331- keys = append(keys, k)
332- }
333- sort.Strings(keys)
334- for _, key := range keys {
335- buf, err := encodeRecord(opSet, []byte(key), s.data[key])
336- if err != nil {
337- _ = s.hooks.Close(f)
338- return err
339- }
340- if n, err := s.hooks.Write(f, buf); err != nil || n != len(buf) {
341- _ = s.hooks.Close(f)
342- if err == nil {
343- err = io.ErrShortWrite
344- }
345- return err
346- }
347- }
348- if err := injectFault(s.hooks, "after-write-next"); err != nil {
349- _ = s.hooks.Close(f)
350- return err
351- }
352- if err := injectFault(s.hooks, "before-sync-next"); err != nil {
353- _ = s.hooks.Close(f)
354- return err
355- }
356- if err := s.hooks.Sync(f); err != nil {
357- _ = s.hooks.Close(f)
358- return err
359- }
360- if err := injectFault(s.hooks, "after-sync-next"); err != nil {
361- _ = s.hooks.Close(f)
362- return err
363- }
364- if err := s.hooks.Close(f); err != nil {
365- return err
366- }
367- if err := validatePath(next); err != nil {
368- return err
369- }
370- dir := filepath.Dir(s.path)
371- if err := s.hooks.SyncDir(dir); err != nil {
372- return err
373- }
374- if err := injectFault(s.hooks, "after-sync-dir-next"); err != nil {
375- return err
376- }
377- if err := removeIfExists(s.hooks, prev); err != nil {
378- return err
379- }
380- if err := injectFault(s.hooks, "after-remove-stale-prev"); err != nil {
381- return err
382- }
383- if err := s.hooks.Rename(s.path, prev); err != nil {
384- return err
385- }
386- if err := injectFault(s.hooks, "after-rename-current-prev"); err != nil {
387- return err
388- }
389- if err := s.hooks.SyncDir(dir); err != nil {
390- return err
391- }
392- if err := injectFault(s.hooks, "after-sync-dir-prev"); err != nil {
393- return err
394- }
395- if err := s.hooks.Rename(next, s.path); err != nil {
396- return err
397- }
398- if err := injectFault(s.hooks, "after-rename-next-current"); err != nil {
399- return err
400- }
401- if err := s.hooks.SyncDir(dir); err != nil {
402- return err
403- }
404- if err := injectFault(s.hooks, "after-sync-dir-current"); err != nil {
405- return err
406- }
407- if err := injectFault(s.hooks, "before-reopen-current"); err != nil {
408- return err
409 }123 }
410- current, err := s.hooks.Open(s.path, os.O_RDWR, 0o600)124+ data := encodeFile(s.data)
125+ tmp := s.path + ".tmp"
126+ file, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
411 if err != nil {127 if err != nil {
412 return err128 return err
413 }129 }
414- if err := injectFault(s.hooks, "after-reopen-current"); err != nil {130+ if err := writeAll(file, data); err != nil {
415- _ = s.hooks.Close(current)131+ return errors.Join(err, file.Close())
416- return err
417- }
418- result, err := replayFile(current)
419- if err != nil || result.TornTail || result.Format != formatCurrent {
420- _ = s.hooks.Close(current)
421- if err == nil {
422- err = ErrCorruptWAL
423- }
424- return err
425- }
426- if _, err := current.Seek(0, io.SeekEnd); err != nil {
427- _ = s.hooks.Close(current)
428- return err
429 }132 }
430- old := s.file133+ if err := file.Sync(); err != nil {
431- s.file = current134+ return errors.Join(err, file.Close())
432- s.data, s.entrySize, s.total, s.live = result.Data, result.EntrySize, result.Total, result.Live
433- if err := s.hooks.Close(old); err != nil {
434- return err
435 }135 }
436- if err := removeIfExists(s.hooks, prev); err != nil {136+ if err := file.Close(); err != nil {
437 return err137 return err
438 }138 }
439- if err := injectFault(s.hooks, "after-remove-prev"); err != nil {139+ if err := os.Rename(tmp, s.path); err != nil {
440 return err140 return err
441 }141 }
442- if err := s.hooks.SyncDir(dir); err != nil {142+ if err := syncDir(filepath.Dir(s.path)); err != nil {
443 return err143 return err
444 }144 }
445- return injectFault(s.hooks, "after-final-sync-dir")145+ s.dirty = false
146+ return nil
446 }147 }
447 148
448-// poisonLocked permanently disables a store whose compaction did not complete.149+// Close syncs buffered mutations before releasing the process lock.
449-// The caller holds s.mu, so no later append can retain a descriptor for a150+func (s *Store) Close() error {
450-// transitional pathname.151+ s.mu.Lock()
451-func (s *Store) poisonLocked() error {152+ defer s.mu.Unlock()
452 if s.closed {153 if s.closed {
453 return nil154 return nil
454 }155 }
156+ err := s.syncLocked()
455 s.closed = true157 s.closed = true
456- file, lockFile := s.file, s.lockFile158+ return errors.Join(err, releaseLock(s.lockFile))
457- s.file, s.lockFile = nil, nil
458- var fileErr error
459- if file != nil {
460- fileErr = s.hooks.Close(file)
461- }
462- return errors.Join(fileErr, releaseLock(lockFile))
463 }159 }
464 160
465-// replayFile validates a WAL prefix in whichever framing the file uses. A torn161+func writeAll(file *os.File, data []byte) error {
466-// tail is the only truncatable outcome; every other inconsistency is corruption162+ for len(data) > 0 {
467-// and fails closed, so a damaged record can never be silently dropped along163+ n, err := file.Write(data)
468-// with everything written after it.
469-func replayFile(f *os.File) (ReplayResult, error) {
470- info, err := f.Stat()
471- if err != nil {
472- return ReplayResult{}, err
473- }
474- if _, err := f.Seek(0, io.SeekStart); err != nil {
475- return ReplayResult{}, err
476- }
477- fileSize := info.Size()
478- format, err := readFileFormat(f, fileSize)
479- if err != nil {
480- return ReplayResult{}, err
481- }
482- result := ReplayResult{Data: make(map[string][]byte), EntrySize: make(map[string]int64), Format: format, LastGood: format.fileHeaderLen()}
483- for {
484- header, status, err := readRecordHeader(f, format, fileSize, result.LastGood)
485- if err != nil {
486- return ReplayResult{}, err
487- }
488- if status == headerEOF {
489- return result, nil
490- }
491- if status == headerTorn {
492- result.TornTail = true
493- return result, nil
494- }
495- payload := make([]byte, int(header.payloadLen))
496- if _, err := io.ReadFull(f, payload); errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
497- result.TornTail = true
498- return result, nil
499- } else if err != nil {
500- return ReplayResult{}, err
501- }
502- if crc32.ChecksumIEEE(payload) != header.payloadCRC {
503- return ReplayResult{}, fmt.Errorf("%w: CRC mismatch at offset %d", ErrCorruptWAL, result.LastGood)
504- }
505- var records []record
506- if len(payload) > 0 && payload[0] == opBatch {
507- records, err = decodeBatch(payload)
508- } else {
509- var rec record
510- rec, err = decodePayload(payload)
511- records = []record{rec}
512- }
513 if err != nil {164 if err != nil {
514- return ReplayResult{}, fmt.Errorf("%w: invalid record at offset %d: %w", ErrCorruptWAL, result.LastGood, err)
515- }
516- size := format.headerLen() + int64(len(payload))
517- applyRecords(result.Data, result.EntrySize, &result.Live, records, size)
518- result.Total += size
519- result.LastGood += size
520- }
521-}
522-
523-// readFileFormat consumes the file header when one is present and leaves the
524-// reader positioned at the first record either way.
525-func readFileFormat(f io.ReadSeeker, fileSize int64) (walFormat, error) {
526- if fileSize == 0 {
527- return formatLegacy, nil
528- }
529- probeLen := min(fileSize, int64(fileHeaderLen))
530- probe := make([]byte, probeLen)
531- if _, err := io.ReadFull(f, probe); err != nil {
532- return formatLegacy, err
533- }
534- if fileSize < fileMagicLen {
535- if bytes.Equal(probe, fileMagic[:len(probe)]) {
536- return formatLegacy, fmt.Errorf("%w: incomplete VEVK header", ErrCorruptWAL)
537- }
538- if _, err := f.Seek(0, io.SeekStart); err != nil {
539- return formatLegacy, err
540- }
541- return formatLegacy, nil
542- }
543- if !bytes.Equal(probe[:fileMagicLen], fileMagic[:]) {
544- // A complete v2 identity checksum authenticates the intended magic as
545- // well as the version. Detect it before legacy replay so damage to the
546- // first magic byte cannot masquerade as a small legacy length and be
547- // truncated or compacted away.
548- if len(probe) == fileHeaderLen &&
549- binary.BigEndian.Uint16(probe[fileMagicLen:fileHeaderBodyLen]) == formatVersion &&
550- binary.BigEndian.Uint32(probe[fileHeaderBodyLen:]) == binary.BigEndian.Uint32(fileHeader()[fileHeaderBodyLen:]) {
551- return formatLegacy, fmt.Errorf("%w: damaged VEVK v2 magic", ErrCorruptWAL)
552- }
553- if _, err := f.Seek(0, io.SeekStart); err != nil {
554- return formatLegacy, err
555- }
556- return formatLegacy, nil
557- }
558- if fileSize < fileHeaderV1Len {
559- return formatLegacy, fmt.Errorf("%w: incomplete VEVK header", ErrCorruptWAL)
560- }
561- prefix := probe[:fileHeaderV1Len]
562- if _, err := f.Seek(fileHeaderV1Len, io.SeekStart); err != nil {
563- return formatLegacy, err
564- }
565- switch version := binary.BigEndian.Uint16(prefix[fileMagicLen:]); version {
566- case formatVersionV1:
567- return formatCurrentV1, nil
568- case formatVersion:
569- checksum := make([]byte, fileHeaderLen-fileHeaderV1Len)
570- if _, err := io.ReadFull(f, checksum); err != nil {
571- return formatLegacy, fmt.Errorf("%w: incomplete VEVK v2 header", ErrCorruptWAL)
572- }
573- if crc32.ChecksumIEEE(prefix) != binary.BigEndian.Uint32(checksum) {
574- return formatLegacy, fmt.Errorf("%w: file header checksum mismatch", ErrCorruptWAL)
575- }
576- return formatCurrent, nil
577- default:
578- return formatLegacy, fmt.Errorf("%w: unsupported format version %d", ErrCorruptWAL, version)
579- }
580-}
581-
582-type recordHeader struct {
583- payloadLen uint32
584- payloadCRC uint32
585-}
586-
587-// readRecordHeader implements the replay decision table. In the current format
588-// the header checksum is verified before the length is trusted for anything:
589-// an unverifiable header is corruption, and only a header that checks out may
590-// declare the record short (a torn tail).
591-func readRecordHeader(f io.Reader, format walFormat, fileSize, offset int64) (recordHeader, headerStatus, error) {
592- buf := make([]byte, format.headerLen())
593- if _, err := io.ReadFull(f, buf); errors.Is(err, io.EOF) {
594- return recordHeader{}, headerEOF, nil
595- } else if errors.Is(err, io.ErrUnexpectedEOF) {
596- return recordHeader{}, headerTorn, nil
597- } else if err != nil {
598- return recordHeader{}, headerOK, err
599- }
600- header := recordHeader{
601- payloadLen: binary.BigEndian.Uint32(buf[0:4]),
602- payloadCRC: binary.BigEndian.Uint32(buf[4:8]),
603- }
604- if format != formatLegacy {
605- if crc32.ChecksumIEEE(buf[0:8]) != binary.BigEndian.Uint32(buf[8:recordHeaderLen]) {
606- return recordHeader{}, headerOK, fmt.Errorf("%w: record header CRC mismatch at offset %d", ErrCorruptWAL, offset)
607- }
608- // The length is now trustworthy, so an implausible one is corruption
609- // rather than a short tail. Checked before any allocation.
610- }
611- // Every framing is allocation-bounded. In legacy framing an oversized
612- // declaration remains corruption rather than driving an attacker-sized
613- // allocation; ordinary short tails retain their released truncation semantics.
614- if header.payloadLen > maxPayloadLen {
615- return recordHeader{}, headerOK, fmt.Errorf("%w: record length %d exceeds maximum at offset %d", ErrCorruptWAL, header.payloadLen, offset)
616- }
617- if int64(header.payloadLen) > fileSize-offset-format.headerLen() {
618- return recordHeader{}, headerTorn, nil
619- }
620- return header, headerOK, nil
621-}
622-
623-func applyRecords(data map[string][]byte, sizes map[string]int64, live *int64, records []record, size int64) {
624- setCount := int64(0)
625- for _, rec := range records {
626- if rec.op == opSet {
627- setCount++
628- }
629- }
630- setSize, remainder := size, int64(0)
631- if setCount > 1 {
632- setSize, remainder = size/setCount, size%setCount
633- }
634- for _, rec := range records {
635- key := string(rec.key)
636- if old, ok := sizes[key]; ok {
637- *live -= old
638- }
639- if rec.op == opSet {
640- entrySize := setSize
641- if remainder > 0 {
642- entrySize++
643- remainder--
644- }
645- data[key] = append([]byte(nil), rec.value...)
646- sizes[key] = entrySize
647- *live += entrySize
648- } else {
649- delete(data, key)
650- delete(sizes, key)
651- }
652- }
653-}
654-
655-func recoverCompaction(path string, hooks fsHooks) error {
656- currentResult, currentExists, currentErr := candidate(path)
657- nextResult, nextExists, nextErr := candidate(path + ".next")
658- _, prevExists, prevErr := candidate(path + ".prev")
659- if nextErr == nil && nextResult.TornTail {
660- nextErr = ErrCorruptWAL
661- }
662- dir := filepath.Dir(path)
663- if currentExists && currentErr == nil {
664- if currentResult.TornTail && prevExists {
665- // A predecessor proves compaction was in flight. The torn current is
666- // ambiguous, so preserve both candidates and fail closed rather than
667- // truncating current and deleting the last complete predecessor.
668- return fmt.Errorf("%w: torn current with predecessor", ErrCorruptWAL)
669- }
670- if nextExists {
671- if err := hooks.Remove(path + ".next"); err != nil {
672- return err
673- }
674- if err := hooks.SyncDir(dir); err != nil {
675- return err
676- }
677- }
678- if prevExists {
679- if err := hooks.Remove(path + ".prev"); err != nil {
680- return err
681- }
682- if err := hooks.SyncDir(dir); err != nil {
683- return err
684- }
685- }
686- return nil
687- }
688- if currentExists && currentErr != nil {
689- // Corruption does not prove which bytes are disposable. Preserve every
690- // candidate for diagnosis/recovery instead of deleting current or a valid
691- // predecessor and fail closed.
692- return fmt.Errorf("%w: invalid current candidate", ErrCorruptWAL)
693- }
694- if !currentExists && (nextExists || prevExists) {
695- // A complete .next is the newest fully published compaction candidate and
696- // wins even when .prev exists but is corrupt. Otherwise a complete .prev
697- // is the only safe rollback. Invalid candidates are never selected.
698- source := ""
699- if nextExists && nextErr == nil {
700- source = path + ".next"
701- } else if prevExists && prevErr == nil {
702- source = path + ".prev"
703- }
704- if source == "" {
705- return fmt.Errorf("%w: no valid current, successor, or predecessor", ErrCorruptWAL)
706- }
707- if err := hooks.Rename(source, path); err != nil {
708 return err165 return err
709 }166 }
710- if err := hooks.SyncDir(dir); err != nil {167+ if n == 0 {
711- return err168+ return io.ErrShortWrite
712- }
713- for _, stale := range []string{path + ".prev", path + ".next"} {
714- if stale != source {
715- if err := removeIfExists(hooks, stale); err != nil {
716- return err
717- }
718- }
719 }169 }
720- return hooks.SyncDir(dir)170+ data = data[n:]
721 }171 }
722 return nil172 return nil
723 }173 }
724 174
725-func candidate(path string) (ReplayResult, bool, error) {175+func acquireLock(path string) (*os.File, error) {
726- f, err := os.Open(path)176+ file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
727- if errors.Is(err, os.ErrNotExist) {
728- return ReplayResult{}, false, nil
729- }
730- if err != nil {
731- return ReplayResult{}, true, err
732- }
733- defer func() { _ = f.Close() }()
734- result, err := replayFile(f)
735- return result, true, err
736-}
737-func validatePath(path string) error {
738- result, exists, err := candidate(path)
739 if err != nil {177 if err != nil {
740- return err178+ return nil, err
741- }
742- // A replacement this package just wrote must be complete and in the current
743- // format; anything else means the write did not land as intended.
744- if !exists || result.TornTail || result.Format != formatCurrent {
745- return ErrCorruptWAL
746 }179 }
747- return nil180+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
748-}181+ _ = file.Close()
749-func removeIfExists(hooks fsHooks, path string) error {182+ return nil, err
750- err := hooks.Remove(path)
751- if errors.Is(err, os.ErrNotExist) {
752- return nil
753 }183 }
754- return err184+ return file, nil
755 }185 }
756-func injectFault(hooks fsHooks, point string) error {186+
757- if hooks.Fault == nil {187+func releaseLock(file *os.File) error {
188+ if file == nil {
758 return nil189 return nil
759 }190 }
760- return hooks.Fault(point)191+ return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
761 }192 }
762 193
763 func syncDir(dir string) error {194 func syncDir(dir string) error {
764- f, err := os.Open(dir)195+ file, err := os.Open(dir)
765 if err != nil {196 if err != nil {
766 return err197 return err
767 }198 }
768- return errors.Join(f.Sync(), f.Close())199+ return errors.Join(file.Sync(), file.Close())
769 }200 }
M pkg/kv/kv_test.go +147 -1076
@@ -1,1121 +1,192 @@
11 package kv
22
33 import (
4- "bytes"
54 "encoding/binary"
65 "errors"
7- "fmt"
8- "hash/crc32"
9- "maps"
106 "os"
117 "path/filepath"
12- "reflect"
138 "testing"
149
15- "github.com/bnema/vev/pkg/safedir"
10+ "github.com/stretchr/testify/require"
1611 )
1712
18-var compactionFaultPoints = []string{
19- "before-write-next", "after-write-next", "before-sync-next", "after-sync-next",
20- "after-sync-dir-next", "after-remove-stale-prev", "after-rename-current-prev",
21- "after-sync-dir-prev", "after-rename-next-current", "after-sync-dir-current",
22- "before-reopen-current", "after-reopen-current", "after-remove-prev", "after-final-sync-dir",
23-}
24-
25-func privatePath(t *testing.T) string {
26- t.Helper()
27- return filepath.Join(t.TempDir(), "vev", "store.kv")
28-}
29-
30-// currentFile frames records the way the store writes them today: a file
31-// header followed by self-checksummed record headers.
32-func currentFile(records ...[]byte) []byte {
33- buf := fileHeader()
34- for _, record := range records {
35- buf = append(buf, record...)
36- }
37- return buf
38-}
39-
40-// v1File is the previously released VEVK framing: magic and uint16 version,
41-// followed directly by the same self-checksummed records used today.
42-func v1File(records ...[]byte) []byte {
43- buf := append([]byte(nil), fileMagic[:]...)
44- buf = binary.BigEndian.AppendUint16(buf, formatVersionV1)
45- for _, record := range records {
46- buf = append(buf, record...)
47- }
48- return buf
49-}
50-
51-// legacyFile reproduces the framing written by the released version: no file
52-// header, and a record header of length plus payload CRC only. Compatibility
53-// with these files is a hard requirement, so the fixture stays in the tests.
54-func legacyFile(records ...[]byte) []byte {
55- var buf []byte
56- for _, record := range records {
57- payload := record[recordHeaderLen:]
58- header := make([]byte, legacyHeaderLen)
59- binary.BigEndian.PutUint32(header[0:4], uint32(len(payload)))
60- binary.BigEndian.PutUint32(header[4:8], crc32.ChecksumIEEE(payload))
61- buf = append(append(buf, header...), payload...)
62- }
63- return buf
64-}
65-
66-func writeFile(t *testing.T, path string, data []byte) {
67- t.Helper()
68- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
69- t.Fatal(err)
70- }
71- if err := os.WriteFile(path, data, 0o600); err != nil {
72- t.Fatal(err)
73- }
74-}
75-
76-func closeStore(t *testing.T, s *Store) {
77- t.Helper()
78- if err := s.Close(); err != nil {
79- t.Fatal(err)
80- }
81-}
82-
83-func TestSetGetDeleteRange(t *testing.T) {
84- path := privatePath(t)
85- s, err := Open(path)
86- if err != nil {
87- t.Fatal(err)
88- }
89- defer closeStore(t, s)
90-
91- if err := s.Set([]byte("b"), []byte("two")); err != nil {
92- t.Fatal(err)
93- }
94- if err := s.Set([]byte("a"), []byte("one")); err != nil {
95- t.Fatal(err)
96- }
97- got, ok := s.Get([]byte("a"))
98- if !ok || string(got) != "one" {
99- t.Fatalf("Get(a) = %q, %v", got, ok)
100- }
101- got[0] = 'X'
102- got, _ = s.Get([]byte("a"))
103- if string(got) != "one" {
104- t.Fatalf("Get returned mutable value: %q", got)
105- }
106- if err := s.Delete([]byte("b")); err != nil {
107- t.Fatal(err)
108- }
109- if _, ok := s.Get([]byte("b")); ok {
110- t.Fatal("deleted key still present")
111- }
112-
113- seen := map[string]string{}
114- s.Range(func(k, v []byte) bool {
115- seen[string(k)] = string(v)
116- return true
117- })
118- if len(seen) != 1 || seen["a"] != "one" {
119- t.Fatalf("Range saw %#v", seen)
120- }
121-}
122-
123-func TestReopenDurability(t *testing.T) {
124- path := privatePath(t)
125- s, err := Open(path)
126- if err != nil {
127- t.Fatal(err)
128- }
129- if err := s.Set([]byte("k"), []byte("v")); err != nil {
130- t.Fatal(err)
131- }
132- if err := s.Close(); err != nil {
133- t.Fatal(err)
134- }
135-
136- s, err = Open(path)
137- if err != nil {
138- t.Fatal(err)
139- }
140- defer closeStore(t, s)
141- got, ok := s.Get([]byte("k"))
142- if !ok || string(got) != "v" {
143- t.Fatalf("after reopen Get(k) = %q, %v", got, ok)
144- }
145-}
146-
147-func TestCompactionShrinksWithDataIntact(t *testing.T) {
148- path := privatePath(t)
149- s, err := Open(path)
150- if err != nil {
151- t.Fatal(err)
152- }
153- if err := s.Set([]byte("keep"), bytes.Repeat([]byte("x"), 128)); err != nil {
154- t.Fatal(err)
155- }
156- info, err := os.Stat(path)
157- if err != nil {
158- t.Fatal(err)
159- }
160- before := info.Size()
161- if err := s.Set([]byte("keep"), []byte("y")); err != nil {
162- t.Fatal(err)
163- }
164- info, err = os.Stat(path)
165- if err != nil {
166- t.Fatal(err)
167- }
168- after := info.Size()
169- if after >= before {
170- t.Fatalf("expected compaction to shrink file: before=%d after=%d", before, after)
171- }
172- got, ok := s.Get([]byte("keep"))
173- if !ok || string(got) != "y" {
174- t.Fatalf("compacted value = %q, %v", got, ok)
175- }
176- if err := s.Close(); err != nil {
177- t.Fatal(err)
178- }
179-}
180-
181-func TestOpenTruncatesMidRecordTail(t *testing.T) {
182- path := privatePath(t)
183- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
184- second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
185- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
186- t.Fatal(err)
187- }
188- if err := os.WriteFile(path, currentFile(first, second[:len(second)/2]), 0o600); err != nil {
189- t.Fatal(err)
190- }
191-
192- s, err := Open(path)
193- if err != nil {
194- t.Fatal(err)
195- }
196- defer closeStore(t, s)
197- if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
198- t.Fatalf("good record missing: %q %v", got, ok)
199- }
200- if _, ok := s.Get([]byte("b")); ok {
201- t.Fatal("torn record was replayed")
202- }
203- info, err := os.Stat(path)
204- if err != nil {
205- t.Fatal(err)
206- }
207- if want := int64(fileHeaderLen + len(first)); info.Size() != want {
208- t.Fatalf("tail not truncated: size=%d want=%d", info.Size(), want)
209- }
210-}
211-
212-func TestCRCcorruptionOfLastRecordFailsClosed(t *testing.T) {
213- path := privatePath(t)
214- first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
215- second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
216- buf := currentFile(first, second)
217- buf[fileHeaderLen+len(first)+recordHeaderLen+payloadPrefixLen+1] ^= 0xff
218- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
219- t.Fatal(err)
220- }
221- if err := os.WriteFile(path, buf, 0o600); err != nil {
222- t.Fatal(err)
223- }
224-
225- _, err := Open(path)
226- if !errors.Is(err, ErrCorruptWAL) {
227- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
228- }
229- info, statErr := os.Stat(path)
230- if statErr != nil {
231- t.Fatal(statErr)
232- }
233- if info.Size() != int64(len(buf)) {
234- t.Fatalf("corrupt WAL was mutated: size=%d want=%d", info.Size(), len(buf))
235- }
236-}
237-
238-func TestAbsentAndEmptyFile(t *testing.T) {
239- path := filepath.Join(t.TempDir(), "missing", "store.kv")
240- m, err := Replay(path)
241- if err != nil {
242- t.Fatal(err)
243- }
244- if len(m) != 0 {
245- t.Fatalf("Replay(absent) = %#v", m)
246- }
247- s, err := Open(path)
248- if err != nil {
249- t.Fatal(err)
250- }
251- if err := s.Close(); err != nil {
252- t.Fatal(err)
253- }
254- m, err = Replay(path)
255- if err != nil {
256- t.Fatal(err)
257- }
258- if len(m) != 0 {
259- t.Fatalf("Replay(empty) = %#v", m)
260- }
261-}
262-
263-func TestTombstoneSurvivesCompaction(t *testing.T) {
264- path := privatePath(t)
265- s, err := Open(path)
266- if err != nil {
267- t.Fatal(err)
268- }
269- if err := s.Set([]byte("dead"), bytes.Repeat([]byte("d"), 128)); err != nil {
270- t.Fatal(err)
271- }
272- if err := s.Set([]byte("keep"), bytes.Repeat([]byte("k"), 128)); err != nil {
273- t.Fatal(err)
274- }
275- if err := s.Delete([]byte("dead")); err != nil {
276- t.Fatal(err)
277- }
278- if err := s.Close(); err != nil {
279- t.Fatal(err)
280- }
281-
282- s, err = Open(path)
283- if err != nil {
284- t.Fatal(err)
285- }
286- defer closeStore(t, s)
287- if _, ok := s.Get([]byte("dead")); ok {
288- t.Fatal("deleted key resurrected after compaction/reopen")
289- }
290- if got, ok := s.Get([]byte("keep")); !ok || len(got) != 128 || got[0] != 'k' {
291- t.Fatalf("live key missing after compaction/reopen: len=%d ok=%v", len(got), ok)
292- }
293-}
294-
295-func TestReplayStrict(t *testing.T) {
296- valid, err := encodeRecord(opSet, []byte("old"), []byte("value"))
297- if err != nil {
298- t.Fatal(err)
299- }
300- batch, err := encodeBatch([]BatchChange{{Key: []byte("old"), Delete: true}, {Key: []byte("new"), Value: []byte("value")}})
301- if err != nil {
302- t.Fatal(err)
303- }
304-
305- tests := []struct {
306- name string
307- wal []byte
308- torn bool
309- corrupt bool
310- }{
311- {name: "torn-final-header", wal: currentFile(valid, batch[:recordHeaderLen-1]), torn: true},
312- {name: "torn-final-payload", wal: currentFile(valid, batch[:len(batch)-1]), torn: true},
313- {name: "final-crc", wal: corruptRecord(currentFile(valid, batch), fileHeaderLen+len(valid)+recordHeaderLen), corrupt: true},
314- {name: "middle-crc", wal: corruptRecord(currentFile(valid, batch), fileHeaderLen+recordHeaderLen), corrupt: true},
315- {name: "invalid-op", wal: currentFile(rawRecord([]byte{0xff, 0, 0})), corrupt: true},
316- {name: "impossible-batch-count", wal: currentFile(rawRecord([]byte{opBatch, 0xff, 0xff, 0xff, 0xff})), corrupt: true},
317- {name: "duplicate-key-in-batch", wal: currentFile(rawRecord(rawBatchPayload([]BatchChange{{Key: []byte("x")}, {Key: []byte("x"), Delete: true}}))), corrupt: true},
318- {name: "trailing-batch-garbage", wal: currentFile(rawRecord(append(rawBatchPayload([]BatchChange{{Key: []byte("x")}}), 0))), corrupt: true},
319- }
320- for _, tt := range tests {
321- t.Run(tt.name, func(t *testing.T) {
322- path := privatePath(t)
323- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
324- t.Fatal(err)
325- }
326- if err := os.WriteFile(path, tt.wal, 0o600); err != nil {
327- t.Fatal(err)
328- }
329- f, err := os.Open(path)
330- if err != nil {
331- t.Fatal(err)
332- }
333- result, replayErr := replayFile(f)
334- _ = f.Close()
335- if tt.corrupt {
336- if !errors.Is(replayErr, ErrCorruptWAL) {
337- t.Fatalf("replay error = %v, want ErrCorruptWAL", replayErr)
338- }
339- return
340- }
341- if replayErr != nil {
342- t.Fatal(replayErr)
343- }
344- if string(result.Data["old"]) != "value" || result.Data["new"] != nil {
345- t.Fatalf("torn batch was partially applied: %#v", result.Data)
346- }
347- if !result.TornTail || result.LastGood != int64(fileHeaderLen+len(valid)) {
348- t.Fatalf("result = %+v", result)
349- }
350- })
351- }
352-}
353-
354-// TestRecoverCompactionCleansStaleNextRegardlessOfPrev covers the branch
355-// where current is valid: any stale .next must be removed even when a .prev
356-// also exists (previously the "nextExists && !prevExists" guard left a .next
357-// behind whenever .prev was present too, so spec §3's complete-cleanup
358-// requirement was not met after certain crash-then-retry sequences).
359-func TestRecoverCompactionCleansStaleNextRegardlessOfPrev(t *testing.T) {
360- oldRecord, _ := encodeRecord(opSet, []byte("version"), []byte("old"))
361- newRecord, _ := encodeRecord(opSet, []byte("version"), []byte("new"))
362- oldFile, newFile := currentFile(oldRecord), currentFile(newRecord)
363- tests := []struct {
364- name string
365- current, next, prev []byte
366- }{
367- {name: "current-next-only", current: oldFile, next: newFile},
368- {name: "current-next-and-prev", current: oldFile, next: newFile, prev: oldFile},
369- }
370- for _, tt := range tests {
371- t.Run(tt.name, func(t *testing.T) {
372- path := privatePath(t)
373- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
374- t.Fatal(err)
375- }
376- for suffix, data := range map[string][]byte{"": tt.current, ".next": tt.next, ".prev": tt.prev} {
377- if data != nil {
378- if err := os.WriteFile(path+suffix, data, 0o600); err != nil {
379- t.Fatal(err)
380- }
381- }
382- }
383- if err := recoverCompaction(path, defaultFSHooks()); err != nil {
384- t.Fatal(err)
385- }
386- if _, err := os.Stat(path + ".next"); !errors.Is(err, os.ErrNotExist) {
387- t.Fatalf(".next survived recovery: err = %v", err)
388- }
389- if _, err := os.Stat(path + ".prev"); !errors.Is(err, os.ErrNotExist) {
390- t.Fatalf(".prev survived recovery: err = %v", err)
391- }
392- got, err := Replay(path)
393- if err != nil {
394- t.Fatal(err)
395- }
396- if string(got["version"]) != "old" {
397- t.Fatalf("version = %q, want %q (current must remain authoritative)", got["version"], "old")
398- }
399- })
400- }
401-}
402-
403-func TestReplayPreservesWrappedDecodeCause(t *testing.T) {
404- path := privatePath(t)
405- writeFile(t, path, currentFile(rawRecord([]byte{0xff, 0, 0})))
406- f, err := os.Open(path)
407- if err != nil {
408- t.Fatal(err)
409- }
410- defer func() { _ = f.Close() }()
411- _, err = replayFile(f)
412- if !errors.Is(err, ErrCorruptWAL) || !errors.Is(err, errBadRecord) {
413- t.Fatalf("replayFile error = %v, want wrapped ErrCorruptWAL and errBadRecord", err)
414- }
415-}
416-
417-func TestBatchLiveSizeAccountsForRemainderExactly(t *testing.T) {
418- data := map[string][]byte{}
419- sizes := map[string]int64{}
420- var live int64
421- applyRecords(data, sizes, &live, []record{
422- {op: opSet, key: []byte("a"), value: []byte("one")},
423- {op: opSet, key: []byte("b"), value: []byte("two")},
424- {op: opSet, key: []byte("c"), value: []byte("three")},
425- }, 10)
426- if live != 10 || sizes["a"]+sizes["b"]+sizes["c"] != 10 {
427- t.Fatalf("live sizes = %v, live = %d, want exact total 10", sizes, live)
428- }
429-}
430-
431-func TestDecodeBatchRejectsImpossibleCountBeforeAllocating(t *testing.T) {
432- payload := []byte{opBatch, 0xff, 0xff, 0xff, 0xff}
433- allocs := testing.AllocsPerRun(10, func() {
434- if _, err := decodeBatch(payload); !errors.Is(err, errBadRecord) {
435- t.Fatalf("decodeBatch error = %v, want errBadRecord", err)
436- }
437- })
438- if allocs != 0 {
439- t.Fatalf("decodeBatch allocations = %v, want 0", allocs)
440- }
441-}
442-
443-func TestBatchAtomicity(t *testing.T) {
444- path := privatePath(t)
445- s, err := Open(path)
446- if err != nil {
447- t.Fatal(err)
448- }
449- defer closeStore(t, s)
450- if err := s.Set([]byte("old"), []byte("before")); err != nil {
451- t.Fatal(err)
452- }
453- if err := s.Batch(nil); err == nil {
454- t.Fatal("empty batch succeeded")
455- }
456- if err := s.Batch([]BatchChange{{Key: []byte("duplicate")}, {Key: []byte("duplicate"), Delete: true}}); err == nil {
457- t.Fatal("duplicate-key batch succeeded")
458- }
459- if err := s.Batch([]BatchChange{{Key: []byte("old"), Delete: true}, {Key: []byte("new"), Value: []byte("same-incarnation")}}); err != nil {
460- t.Fatal(err)
461- }
462- if err := s.Sync(); err != nil {
463- t.Fatal(err)
464- }
465- if _, ok := s.Get([]byte("old")); ok {
466- t.Fatal("old key remains")
467- }
468- got, ok := s.Get([]byte("new"))
469- if !ok || !bytes.Equal(got, []byte("same-incarnation")) {
470- t.Fatalf("new = %q, %v", got, ok)
471- }
472-}
473-
474-func TestCompactionRecoveryMatrix(t *testing.T) {
475- oldRecord, _ := encodeRecord(opSet, []byte("version"), []byte("old"))
476- newRecord, _ := encodeRecord(opSet, []byte("version"), []byte("new"))
477- oldFile, newFile := currentFile(oldRecord), currentFile(newRecord)
478- tests := []struct {
479- name string
480- current, next, prev []byte
481- want string
482- wantErr bool
483- }{
484- {name: "current-alone", current: oldFile, want: "old"},
485- {name: "current-next", current: oldFile, next: newFile, want: "old"},
486- {name: "prev-next", prev: oldFile, next: newFile, want: "new"},
487- {name: "next-alone", next: newFile, want: "new"},
488- {name: "invalid-prev-valid-next", prev: currentFile(rawRecord([]byte{0xff, 0, 0})), next: newFile, want: "new"},
489- {name: "valid-prev-invalid-next", prev: oldFile, next: currentFile(rawRecord([]byte{0xff, 0, 0})), want: "old"},
490- {name: "prev-torn-next", prev: oldFile, next: newFile[:len(newFile)-1], want: "old"},
491- {name: "current-prev", current: newFile, prev: oldFile, want: "new"},
492- {name: "invalid-current-prev", current: currentFile(rawRecord([]byte{0xff, 0, 0})), prev: oldFile, wantErr: true},
493- {name: "prev-alone", prev: oldFile, want: "old"},
494- }
495- for _, tt := range tests {
496- t.Run(tt.name, func(t *testing.T) {
497- path := privatePath(t)
498- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
499- t.Fatal(err)
500- }
501- for suffix, data := range map[string][]byte{"": tt.current, ".next": tt.next, ".prev": tt.prev} {
502- if data != nil {
503- if err := os.WriteFile(path+suffix, data, 0o600); err != nil {
504- t.Fatal(err)
505- }
506- }
507- }
508- before := candidateBytes(t, path)
509- err := recoverCompaction(path, defaultFSHooks())
510- if tt.wantErr {
511- if !errors.Is(err, ErrCorruptWAL) {
512- t.Fatalf("recoverCompaction error = %v, want ErrCorruptWAL", err)
513- }
514- if got := candidateBytes(t, path); !reflect.DeepEqual(got, before) {
515- t.Fatalf("uncertain candidates changed: got %#v, want %#v", got, before)
516- }
517- return
518- }
519- if err != nil {
520- t.Fatal(err)
521- }
522- got, err := Replay(path)
523- if err != nil {
524- t.Fatal(err)
525- }
526- if string(got["version"]) != tt.want {
527- t.Fatalf("version = %q, want %q", got["version"], tt.want)
528- }
529- })
530- }
531-
532- for _, point := range compactionFaultPoints {
533- t.Run("fault-"+point, func(t *testing.T) {
534- path := privatePath(t)
535- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
536- t.Fatal(err)
537- }
538- if err := os.WriteFile(path, oldFile, 0o600); err != nil {
539- t.Fatal(err)
540- }
541- s, err := Open(path)
542- if err != nil {
543- t.Fatal(err)
544- }
545- s.data = map[string][]byte{"version": []byte("new")}
546- s.hooks.Fault = func(got string) error {
547- if got == point {
548- return errors.New("injected " + point)
549- }
550- return nil
551- }
552- if err := s.compactLocked(); err == nil {
553- t.Fatalf("fault %q did not interrupt compaction", point)
554- }
555- beforeAppend := candidateBytes(t, path)
556- if err := s.Set([]byte("later"), []byte("must-not-append")); !errors.Is(err, os.ErrClosed) {
557- t.Fatalf("append after compaction fault = %v, want closed store", err)
558- }
559- requireCandidateBytes(t, path, beforeAppend)
560- if err := recoverCompaction(path, defaultFSHooks()); err != nil {
561- t.Fatalf("recovery: %v", err)
562- }
563- got, err := Replay(path)
564- if err != nil {
565- t.Fatal(err)
566- }
567- version := string(got["version"])
568- if (version != "old" && version != "new") || len(got) != 1 {
569- t.Fatalf("mixed or empty recovered map: %#v", got)
570- }
571- })
572- }
573-}
574-
575-func TestCompactionFilesystemOperationFailuresPoisonAndRecover(t *testing.T) {
576- fault := errors.New("injected filesystem failure")
13+func TestSyncCrashBoundaries(t *testing.T) {
14+ t.Parallel()
57715 tests := []struct {
57816 name string
579- configure func(fsHooks, string) fsHooks
17+ leaveTmp bool
18+ corrupt bool
19+ wantOpen bool
20+ wantValue string
58021 }{
581- {name: "write-next", configure: func(h fsHooks, _ string) fsHooks {
582- h.Write = func(*os.File, []byte) (int, error) { return 0, fault }
583- return h
584- }},
585- {name: "sync-next", configure: func(h fsHooks, _ string) fsHooks {
586- h.Sync = func(*os.File) error { return fault }
587- return h
588- }},
589- {name: "close-next", configure: func(h fsHooks, _ string) fsHooks {
590- calls := 0
591- h.Close = func(f *os.File) error {
592- calls++
593- if calls == 1 {
594- return errors.Join(f.Close(), fault)
595- }
596- return f.Close()
597- }
598- return h
599- }},
600- {name: "remove-stale-prev", configure: failRemoveCall(fault, 1)},
601- {name: "rename-current-prev", configure: failRenameCall(fault, 1)},
602- {name: "sync-dir-next", configure: failSyncDirCall(fault, 1)},
603- {name: "sync-dir-prev", configure: failSyncDirCall(fault, 2)},
604- {name: "rename-next-current", configure: failRenameCall(fault, 2)},
605- {name: "sync-dir-current", configure: failSyncDirCall(fault, 3)},
606- {name: "reopen-current", configure: func(h fsHooks, path string) fsHooks {
607- open := h.Open
608- h.Open = func(name string, flags int, mode os.FileMode) (*os.File, error) {
609- if name == path && flags == os.O_RDWR {
610- return nil, fault
611- }
612- return open(name, flags, mode)
613- }
614- return h
615- }},
616- {name: "close-old-current", configure: func(h fsHooks, _ string) fsHooks {
617- calls := 0
618- h.Close = func(f *os.File) error {
619- calls++
620- if calls == 2 {
621- return errors.Join(f.Close(), fault)
622- }
623- return f.Close()
624- }
625- return h
626- }},
627- {name: "remove-prev", configure: failRemoveCall(fault, 2)},
628- {name: "sync-dir-final", configure: failSyncDirCall(fault, 4)},
22+ {name: "clean file opens", wantOpen: true, wantValue: "v1"},
23+ {name: "stray tmp is ignored", leaveTmp: true, wantOpen: true, wantValue: "v1"},
24+ {name: "corrupt file fails closed", corrupt: true},
62925 }
630- oldRecord, err := encodeRecord(opSet, []byte("version"), []byte("old"))
631- if err != nil {
632- t.Fatal(err)
633- }
634- oldFile := currentFile(oldRecord)
63526 for _, tt := range tests {
63627 t.Run(tt.name, func(t *testing.T) {
637- path := privatePath(t)
638- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
639- t.Fatal(err)
640- }
641- if err := os.WriteFile(path, oldFile, 0o600); err != nil {
642- t.Fatal(err)
643- }
644- s, err := Open(path)
645- if err != nil {
646- t.Fatal(err)
647- }
648- s.data = map[string][]byte{"version": []byte("new")}
649- s.hooks = tt.configure(s.hooks, path)
28+ t.Parallel()
29+ path := privateStorePath(t)
30+ store, err := Open(path)
31+ require.NoError(t, err)
32+ require.NoError(t, store.Set([]byte("k"), []byte("v1")))
33+ require.NoError(t, store.Sync())
34+ require.NoError(t, store.Close())
65035
651- err = s.compactLocked()
652- if !errors.Is(err, fault) {
653- t.Fatalf("compaction error = %v, want injected fault", err)
36+ if tt.leaveTmp {
37+ require.NoError(t, os.WriteFile(path+".tmp", []byte("half written"), 0o600))
65438 }
655- beforeAppend := candidateBytes(t, path)
656- if err := s.Set([]byte("later"), []byte("must-not-append")); !errors.Is(err, os.ErrClosed) {
657- t.Fatalf("append after compaction fault = %v, want closed store", err)
39+ if tt.corrupt {
40+ data, err := os.ReadFile(path)
41+ require.NoError(t, err)
42+ data[len(data)-1] ^= 0xff
43+ require.NoError(t, os.WriteFile(path, data, 0o600))
65844 }
659- requireCandidateBytes(t, path, beforeAppend)
66045
661- if err := recoverCompaction(path, defaultFSHooks()); err != nil {
662- t.Fatalf("recovery: %v", err)
663- }
664- got, err := Replay(path)
665- if err != nil {
666- t.Fatal(err)
667- }
668- version := string(got["version"])
669- if (version != "old" && version != "new") || len(got) != 1 {
670- t.Fatalf("mixed or empty recovered map: %#v", got)
46+ reopened, err := Open(path)
47+ if !tt.wantOpen {
48+ require.ErrorIs(t, err, ErrCorrupt)
49+ return
67150 }
51+ require.NoError(t, err)
52+ t.Cleanup(func() { _ = reopened.Close() })
53+ got, ok := reopened.Get([]byte("k"))
54+ require.True(t, ok)
55+ require.Equal(t, tt.wantValue, string(got))
67256 })
67357 }
67458 }
67559
676-func failRemoveCall(fault error, target int) func(fsHooks, string) fsHooks {
677- return func(h fsHooks, _ string) fsHooks {
678- remove := h.Remove
679- calls := 0
680- h.Remove = func(path string) error {
681- calls++
682- if calls == target {
683- return fault
684- }
685- return remove(path)
686- }
687- return h
688- }
689-}
690-
691-func failRenameCall(fault error, target int) func(fsHooks, string) fsHooks {
692- return func(h fsHooks, _ string) fsHooks {
693- rename := h.Rename
694- calls := 0
695- h.Rename = func(oldPath, newPath string) error {
696- calls++
697- if calls == target {
698- return fault
699- }
700- return rename(oldPath, newPath)
701- }
702- return h
703- }
704-}
705-
706-func failSyncDirCall(fault error, target int) func(fsHooks, string) fsHooks {
707- return func(h fsHooks, _ string) fsHooks {
708- sync := h.SyncDir
709- calls := 0
710- h.SyncDir = func(path string) error {
711- calls++
712- if calls == target {
713- return fault
714- }
715- return sync(path)
716- }
717- return h
718- }
719-}
60+func TestSyncIsAtomicAcrossKeys(t *testing.T) {
61+ t.Parallel()
62+ path := privateStorePath(t)
63+ store, err := Open(path)
64+ require.NoError(t, err)
65+ require.NoError(t, store.Set([]byte("old"), []byte("x")))
66+ require.NoError(t, store.Sync())
67+ require.NoError(t, store.Delete([]byte("old")))
68+ require.NoError(t, store.Set([]byte("new"), []byte("x")))
69+ require.NoError(t, store.Sync())
70+ require.NoError(t, store.Close())
72071
721-func candidateBytes(t *testing.T, path string) map[string][]byte {
722- t.Helper()
723- got := make(map[string][]byte, 3)
724- for _, suffix := range []string{"", ".next", ".prev"} {
725- data, err := os.ReadFile(path + suffix)
726- if errors.Is(err, os.ErrNotExist) {
727- continue
728- }
729- if err != nil {
730- t.Fatal(err)
731- }
732- got[suffix] = data
733- }
734- return got
735-}
736-
737-func requireCandidateBytes(t *testing.T, path string, want map[string][]byte) {
738- t.Helper()
739- got := candidateBytes(t, path)
740- if !reflect.DeepEqual(got, want) {
741- t.Fatalf("catalogue candidates changed after poisoned append: got %#v, want %#v", got, want)
742- }
743-}
744-
745-func corruptRecord(buf []byte, offset int) []byte { buf[offset] ^= 0xff; return buf }
746-func rawRecord(payload []byte) []byte { return framePayload(payload) }
747-func rawBatchPayload(changes []BatchChange) []byte {
748- payload := []byte{opBatch, 0, 0, 0, byte(len(changes))}
749- for _, change := range changes {
750- op := opSet
751- if change.Delete {
752- op = opDel
753- }
754- payload = append(payload, op, 0, byte(len(change.Key)))
755- payload = append(payload, change.Key...)
756- payload = binary.BigEndian.AppendUint32(payload, uint32(len(change.Value)))
757- payload = append(payload, change.Value...)
758- }
759- return payload
72+ reopened, err := Open(path)
73+ require.NoError(t, err)
74+ t.Cleanup(func() { _ = reopened.Close() })
75+ _, hadOld := reopened.Get([]byte("old"))
76+ _, hasNew := reopened.Get([]byte("new"))
77+ require.False(t, hadOld)
78+ require.True(t, hasNew)
76079 }
76180
762-// An oversized length must never drive an allocation. Legacy framing is
763-// accepted only after complete strict replay, while the current header checksum
764-// proves an oversized length was written that way; both fail closed.
765-func TestCorruptedCurrentHeaderNeverFallsBackToLegacyOrDeletesPredecessor(t *testing.T) {
766- record, _ := encodeRecord(opSet, []byte("version"), []byte("current"))
767- previous, _ := encodeRecord(opSet, []byte("version"), []byte("previous"))
768-
81+func TestOpenFailsClosedWithoutMutation(t *testing.T) {
82+ t.Parallel()
76983 tests := []struct {
770- name string
771- current []byte
772- standalone bool
84+ name string
85+ raw []byte
77386 }{
774- {name: "torn file header", current: append([]byte(nil), fileHeader()[:fileHeaderLen-1]...), standalone: true},
775- {name: "torn current record", current: currentFile(record)[:fileHeaderLen+recordHeaderLen+1]},
776- }
777- for i := range fileMagicLen {
778- corrupted := currentFile(record)
779- corrupted[i] = 0
780- tests = append(tests, struct {
781- name string
782- current []byte
783- standalone bool
784- }{name: fmt.Sprintf("corrupted magic byte %d", i), current: corrupted, standalone: true})
87+ {name: "empty", raw: []byte{}},
88+ {name: "short magic", raw: []byte("VE")},
89+ {name: "unknown magic", raw: []byte("NOPE\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00")},
90+ {name: "unknown version", raw: func() []byte { b := validEmptyFile(); binary.BigEndian.PutUint16(b[4:6], 99); return b }()},
91+ {name: "truncated entry", raw: append(validEmptyFile()[:10], 0, 0, 0, 1)},
78592 }
786- for length := 1; length < fileHeaderLen; length++ {
787- tests = append(tests, struct {
788- name string
789- current []byte
790- standalone bool
791- }{name: fmt.Sprintf("torn header prefix %d", length), current: append([]byte(nil), fileHeader()[:length]...), standalone: true})
792- }
793-
79493 for _, tt := range tests {
79594 t.Run(tt.name, func(t *testing.T) {
796- for _, withCandidates := range []bool{false, true} {
797- if !withCandidates && !tt.standalone {
798- continue
799- }
800- name := "standalone"
801- if withCandidates {
802- name = "with candidates"
803- }
804- t.Run(name, func(t *testing.T) {
805- path := privatePath(t)
806- writeFile(t, path, tt.current)
807- if withCandidates {
808- if err := os.WriteFile(path+".prev", currentFile(previous), 0o600); err != nil {
809- t.Fatal(err)
810- }
811- if err := os.WriteFile(path+".next", currentFile(record), 0o600); err != nil {
812- t.Fatal(err)
813- }
814- }
815- before := candidateBytes(t, path)
816- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
817- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
818- }
819- if got := candidateBytes(t, path); !reflect.DeepEqual(got, before) {
820- t.Fatalf("candidates changed: got %#v, want %#v", got, before)
821- }
822- })
823- }
95+ t.Parallel()
96+ path := privateStorePath(t)
97+ require.NoError(t, os.WriteFile(path, tt.raw, 0o600))
98+ _, err := Open(path)
99+ require.ErrorIs(t, err, ErrCorrupt)
100+ after, readErr := os.ReadFile(path)
101+ require.NoError(t, readErr)
102+ require.Equal(t, tt.raw, after)
824103 })
825104 }
826105 }
827106
828-func TestReplayRejectsOversizedLengthWithoutAllocating(t *testing.T) {
829- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
830- legacyHeader := make([]byte, legacyHeaderLen)
831- binary.BigEndian.PutUint32(legacyHeader[0:4], 1<<31)
832-
833- t.Run("legacy incomplete record fails closed", func(t *testing.T) {
834- path := privatePath(t)
835- wal := append(legacyFile(first), legacyHeader...)
836- writeFile(t, path, wal)
837- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
838- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
839- }
840- requireFileBytes(t, path, wal)
841- })
842-
843- t.Run("current oversized length fails closed", func(t *testing.T) {
844- path := privatePath(t)
845- wal := currentFile(first, oversizedRecordHeader(maxPayloadLen+1))
846- writeFile(t, path, wal)
847- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
848- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
849- }
850- requireFileBytes(t, path, wal)
107+func TestCloseSyncsDirtyStore(t *testing.T) {
108+ t.Parallel()
109+ path := privateStorePath(t)
110+ store, err := Open(path)
111+ require.NoError(t, err)
112+ require.NoError(t, store.Set([]byte("k"), []byte("v")))
113+ require.NoError(t, store.Close())
114+
115+ reopened, err := Open(path)
116+ require.NoError(t, err)
117+ t.Cleanup(func() { _ = reopened.Close() })
118+ got, ok := reopened.Get([]byte("k"))
119+ require.True(t, ok)
120+ require.Equal(t, []byte("v"), got)
121+}
122+
123+func TestStoreCopiesValuesAndRangesInKeyOrder(t *testing.T) {
124+ t.Parallel()
125+ store, err := Open(privateStorePath(t))
126+ require.NoError(t, err)
127+ t.Cleanup(func() { _ = store.Close() })
128+ value := []byte("two")
129+ require.NoError(t, store.Set([]byte("b"), value))
130+ value[0] = 'x'
131+ require.NoError(t, store.Set([]byte("a"), []byte("one")))
132+ got, ok := store.Get([]byte("b"))
133+ require.True(t, ok)
134+ require.Equal(t, []byte("two"), got)
135+ got[0] = 'x'
136+ gotAgain, _ := store.Get([]byte("b"))
137+ require.Equal(t, []byte("two"), gotAgain)
138+
139+ var keys []string
140+ store.Range(func(k, v []byte) bool {
141+ keys = append(keys, string(k))
142+ return true
851143 })
144+ require.Equal(t, []string{"a", "b"}, keys)
852145 }
853146
854-// oversizedRecordHeader is a record header whose own checksum is valid, so the
855-// length is provably what the writer put there rather than a bit flip.
856-func oversizedRecordHeader(payloadLen uint32) []byte {
857- header := make([]byte, recordHeaderLen)
858- binary.BigEndian.PutUint32(header[0:4], payloadLen)
859- binary.BigEndian.PutUint32(header[8:recordHeaderLen], crc32.ChecksumIEEE(header[0:8]))
860- return header
147+func TestStoreLockAndClosedErrors(t *testing.T) {
148+ t.Parallel()
149+ path := privateStorePath(t)
150+ store, err := Open(path)
151+ require.NoError(t, err)
152+ _, err = Open(path)
153+ require.Error(t, err)
154+ require.NoError(t, store.Close())
155+ require.NoError(t, store.Close())
156+ require.ErrorIs(t, store.Set([]byte("k"), []byte("v")), os.ErrClosed)
157+ require.ErrorIs(t, store.Delete([]byte("k")), os.ErrClosed)
158+ require.ErrorIs(t, store.Sync(), os.ErrClosed)
861159 }
862160
863-func requireFileBytes(t *testing.T, path string, want []byte) {
161+func privateStorePath(t *testing.T) string {
864162 t.Helper()
865- got, err := os.ReadFile(path)
866- if err != nil {
867- t.Fatal(err)
868- }
869- if !bytes.Equal(got, want) {
870- t.Fatalf("file was mutated: got %d bytes, want %d", len(got), len(want))
871- }
872-}
873-
874-// The regression this format exists for: a bit flip in the length field of an
875-// early record used to look exactly like a torn tail, so Open truncated every
876-// record written after it and reopened a smaller, structurally valid file.
877-func TestCorruptLengthOfEarlyRecordFailsClosedWithoutTruncating(t *testing.T) {
878- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
879- second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
880- third, _ := encodeRecord(opSet, []byte("c"), []byte("three"))
881-
882- tests := []struct {
883- name string
884- offset int
885- }{
886- {name: "first-record-length", offset: fileHeaderLen},
887- {name: "second-record-length", offset: fileHeaderLen + len(first) + 3},
888- }
889- for _, tt := range tests {
890- t.Run(tt.name, func(t *testing.T) {
891- path := privatePath(t)
892- wal := currentFile(first, second, third)
893- // Flip a high bit so the length claims far more than the file holds:
894- // the exact shape that used to be misread as an incomplete tail.
895- wal[tt.offset] ^= 0x40
896- writeFile(t, path, wal)
897-
898- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
899- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
900- }
901- requireFileBytes(t, path, wal)
902- if _, err := Replay(path); !errors.Is(err, ErrCorruptWAL) {
903- t.Fatalf("Replay error = %v, want ErrCorruptWAL", err)
904- }
905- })
906- }
907-}
908-
909-// Every row of the replay decision table for the current format. A torn tail is
910-// the only truncatable outcome.
911-func TestCurrentFormatReplayDecisionTable(t *testing.T) {
912- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
913- second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
914- invalidOp := rawRecord([]byte{0xff, 0, 0})
915-
916- tests := []struct {
917- name string
918- wal []byte
919- torn bool
920- corrupt bool
921- }{
922- {name: "partial-header", wal: currentFile(first, second[:recordHeaderLen-1]), torn: true},
923- {name: "header-checksum-mismatch", wal: corruptRecord(currentFile(first, second), fileHeaderLen+len(first)+1), corrupt: true},
924- {name: "short-payload", wal: currentFile(first, second[:len(second)-1]), torn: true},
925- {name: "payload-checksum-mismatch", wal: corruptRecord(currentFile(first, second), fileHeaderLen+len(first)+recordHeaderLen), corrupt: true},
926- {name: "undecodable-payload", wal: currentFile(first, invalidOp), corrupt: true},
927- {name: "oversized-length", wal: currentFile(first, oversizedRecordHeader(maxPayloadLen+1)), corrupt: true},
928- {name: "complete-records", wal: currentFile(first, second)},
929- }
930- for _, tt := range tests {
931- t.Run(tt.name, func(t *testing.T) {
932- path := privatePath(t)
933- writeFile(t, path, tt.wal)
934- f, err := os.Open(path)
935- if err != nil {
936- t.Fatal(err)
937- }
938- result, err := replayFile(f)
939- _ = f.Close()
940-
941- if tt.corrupt {
942- if !errors.Is(err, ErrCorruptWAL) {
943- t.Fatalf("replay error = %v, want ErrCorruptWAL", err)
944- }
945- return
946- }
947- if err != nil {
948- t.Fatal(err)
949- }
950- if result.Format != formatCurrent {
951- t.Fatalf("format = %d, want current", result.Format)
952- }
953- if result.TornTail != tt.torn {
954- t.Fatalf("TornTail = %v, want %v", result.TornTail, tt.torn)
955- }
956- if string(result.Data["a"]) != "one" {
957- t.Fatalf("prefix lost: %#v", result.Data)
958- }
959- wantLastGood := int64(fileHeaderLen + len(first))
960- if !tt.torn {
961- wantLastGood += int64(len(second))
962- }
963- if result.LastGood != wantLastGood {
964- t.Fatalf("LastGood = %d, want %d", result.LastGood, wantLastGood)
965- }
966- })
967- }
968-}
969-
970-// A file written by the released version must keep working: it opens with the
971-// original semantics, is upgraded in place, and reopens identically.
972-func TestVEVKV1FileOpensAndUpgradesInPlace(t *testing.T) {
973- setA, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
974- setB, _ := encodeRecord(opSet, []byte("b"), bytes.Repeat([]byte("x"), 1<<20))
975- for _, tc := range []struct {
976- name string
977- wal []byte
978- }{
979- {name: "ordinary catalogue payload", wal: v1File(setA)},
980- {name: "prior large catalogue payload", wal: v1File(setB)},
981- } {
982- t.Run(tc.name, func(t *testing.T) {
983- path := privatePath(t)
984- writeFile(t, path, tc.wal)
985- store, err := Open(path)
986- if err != nil {
987- t.Fatal(err)
988- }
989- closeStore(t, store)
990- upgraded, err := os.ReadFile(path)
991- if err != nil {
992- t.Fatal(err)
993- }
994- if !bytes.Equal(upgraded[:fileHeaderLen], fileHeader()) {
995- t.Fatalf("upgraded header = %x, want %x", upgraded[:fileHeaderLen], fileHeader())
996- }
997- })
998- }
999-}
1000-
1001-func TestVEVKV1RejectsOversizedRecordBeforeAllocation(t *testing.T) {
1002- path := privatePath(t)
1003- writeFile(t, path, v1File(oversizedRecordHeader(maxPayloadLen+1)))
1004- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
1005- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
1006- }
1007-}
1008-
1009-func TestLegacyFileOpensAndUpgradesInPlace(t *testing.T) {
1010- setA, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
1011- setB, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
1012- replaceA, _ := encodeRecord(opSet, []byte("a"), []byte("uno"))
1013- delB, _ := encodeRecord(opDel, []byte("b"), nil)
1014- torn, _ := encodeRecord(opSet, []byte("c"), []byte("three"))
1015- tests := []struct {
1016- name string
1017- wal []byte
1018- want map[string]string
1019- }{
1020- {name: "complete", wal: legacyFile(setA, setB, replaceA), want: map[string]string{"a": "uno", "b": "two"}},
1021- {name: "tombstone", wal: legacyFile(setA, setB, delB), want: map[string]string{"a": "one"}},
1022- {name: "torn-tail", wal: append(legacyFile(setA, setB), legacyFile(torn)[:legacyHeaderLen+2]...), want: map[string]string{"a": "one", "b": "two"}},
1023- {name: "empty", wal: nil, want: map[string]string{}},
1024- }
1025- for _, tt := range tests {
1026- t.Run(tt.name, func(t *testing.T) {
1027- path := privatePath(t)
1028- writeFile(t, path, tt.wal)
1029-
1030- s, err := Open(path)
1031- if err != nil {
1032- t.Fatalf("legacy file failed to open: %v", err)
1033- }
1034- requireContents(t, s, tt.want)
1035- if err := s.Set([]byte("added"), []byte("after-upgrade")); err != nil {
1036- t.Fatal(err)
1037- }
1038- closeStore(t, s)
1039-
1040- upgraded, err := os.ReadFile(path)
1041- if err != nil {
1042- t.Fatal(err)
1043- }
1044- if !bytes.HasPrefix(upgraded, fileMagic[:]) {
1045- t.Fatal("legacy file was not upgraded in place")
1046- }
1047- reopened, err := Open(path)
1048- if err != nil {
1049- t.Fatal(err)
1050- }
1051- defer closeStore(t, reopened)
1052- want := map[string]string{"added": "after-upgrade"}
1053- maps.Copy(want, tt.want)
1054- requireContents(t, reopened, want)
1055- replayed, err := Replay(path)
1056- if err != nil {
1057- t.Fatal(err)
1058- }
1059- if len(replayed) != len(want) {
1060- t.Fatalf("Replay = %#v, want %#v", replayed, want)
1061- }
1062- })
1063- }
1064-}
1065-
1066-func requireContents(t *testing.T, s *Store, want map[string]string) {
1067- t.Helper()
1068- for key, value := range want {
1069- got, ok := s.Get([]byte(key))
1070- if !ok || string(got) != value {
1071- t.Fatalf("Get(%q) = %q, %v; want %q", key, got, ok, value)
1072- }
1073- }
1074- count := 0
1075- s.Range(func([]byte, []byte) bool { count++; return true })
1076- if count != len(want) {
1077- t.Fatalf("store holds %d keys, want %d", count, len(want))
1078- }
1079-}
1080-
1081-// The upgrade runs through the compaction protocol, so a crash at any of its
1082-// boundaries must leave either the whole legacy file or the whole upgraded one.
1083-func TestLegacyUpgradeCrashSafety(t *testing.T) {
1084- setA, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
1085- setB, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
1086- legacy := legacyFile(setA, setB)
1087- want := map[string]string{"a": "one", "b": "two"}
1088-
1089- for _, point := range compactionFaultPoints {
1090- t.Run("fault-"+point, func(t *testing.T) {
1091- path := privatePath(t)
1092- writeFile(t, path, legacy)
1093- hooks := defaultFSHooks()
1094- hooks.Fault = func(got string) error {
1095- if got == point {
1096- return errors.New("injected " + point)
1097- }
1098- return nil
1099- }
1100- if _, err := openWithHooks(path, hooks); err == nil {
1101- t.Fatalf("fault %q did not interrupt the upgrade", point)
1102- }
1103-
1104- // Restart: recovery selects a complete file, and the data is intact
1105- // whichever format that file happens to be in.
1106- s, err := Open(path)
1107- if err != nil {
1108- t.Fatalf("restart after fault %q: %v", point, err)
1109- }
1110- requireContents(t, s, want)
1111- closeStore(t, s)
1112- upgraded, err := os.ReadFile(path)
1113- if err != nil {
1114- t.Fatal(err)
1115- }
1116- if !bytes.HasPrefix(upgraded, fileMagic[:]) {
1117- t.Fatal("restart did not complete the upgrade")
1118- }
1119- })
1120- }
163+ dir := filepath.Join(t.TempDir(), "vev")
164+ require.NoError(t, os.Mkdir(dir, 0o700))
165+ return filepath.Join(dir, "sessions.kv")
166+}
167+
168+func validEmptyFile() []byte {
169+ return encodeFile(map[string][]byte{})
170+}
171+
172+func TestDecodeRejectsDuplicateKeysAndTrailingData(t *testing.T) {
173+ t.Parallel()
174+ payload := make([]byte, 0)
175+ payload = append(payload, fileMagic[:]...)
176+ payload = binary.BigEndian.AppendUint16(payload, fileVersion)
177+ payload = binary.BigEndian.AppendUint32(payload, 2)
178+ for range 2 {
179+ payload = binary.BigEndian.AppendUint32(payload, 1)
180+ payload = append(payload, 'k')
181+ payload = binary.BigEndian.AppendUint32(payload, 1)
182+ payload = append(payload, 'v')
183+ }
184+ raw := appendCRC(payload)
185+ _, err := decodeFile(raw)
186+ require.ErrorIs(t, err, ErrCorrupt)
187+
188+ valid := validEmptyFile()
189+ valid = append(valid, 0)
190+ _, err = decodeFile(valid)
191+ require.True(t, errors.Is(err, ErrCorrupt))
1121192 }
@@ -1,1121 +1,192 @@
1 package kv1 package kv
2 2
3 import (3 import (
4- "bytes"
5 "encoding/binary"4 "encoding/binary"
6 "errors"5 "errors"
7- "fmt"
8- "hash/crc32"
9- "maps"
10 "os"6 "os"
11 "path/filepath"7 "path/filepath"
12- "reflect"
13 "testing"8 "testing"
14 9
15- "github.com/bnema/vev/pkg/safedir"10+ "github.com/stretchr/testify/require"
16 )11 )
17 12
18-var compactionFaultPoints = []string{13+func TestSyncCrashBoundaries(t *testing.T) {
19- "before-write-next", "after-write-next", "before-sync-next", "after-sync-next",14+ t.Parallel()
20- "after-sync-dir-next", "after-remove-stale-prev", "after-rename-current-prev",
21- "after-sync-dir-prev", "after-rename-next-current", "after-sync-dir-current",
22- "before-reopen-current", "after-reopen-current", "after-remove-prev", "after-final-sync-dir",
23-}
24-
25-func privatePath(t *testing.T) string {
26- t.Helper()
27- return filepath.Join(t.TempDir(), "vev", "store.kv")
28-}
29-
30-// currentFile frames records the way the store writes them today: a file
31-// header followed by self-checksummed record headers.
32-func currentFile(records ...[]byte) []byte {
33- buf := fileHeader()
34- for _, record := range records {
35- buf = append(buf, record...)
36- }
37- return buf
38-}
39-
40-// v1File is the previously released VEVK framing: magic and uint16 version,
41-// followed directly by the same self-checksummed records used today.
42-func v1File(records ...[]byte) []byte {
43- buf := append([]byte(nil), fileMagic[:]...)
44- buf = binary.BigEndian.AppendUint16(buf, formatVersionV1)
45- for _, record := range records {
46- buf = append(buf, record...)
47- }
48- return buf
49-}
50-
51-// legacyFile reproduces the framing written by the released version: no file
52-// header, and a record header of length plus payload CRC only. Compatibility
53-// with these files is a hard requirement, so the fixture stays in the tests.
54-func legacyFile(records ...[]byte) []byte {
55- var buf []byte
56- for _, record := range records {
57- payload := record[recordHeaderLen:]
58- header := make([]byte, legacyHeaderLen)
59- binary.BigEndian.PutUint32(header[0:4], uint32(len(payload)))
60- binary.BigEndian.PutUint32(header[4:8], crc32.ChecksumIEEE(payload))
61- buf = append(append(buf, header...), payload...)
62- }
63- return buf
64-}
65-
66-func writeFile(t *testing.T, path string, data []byte) {
67- t.Helper()
68- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
69- t.Fatal(err)
70- }
71- if err := os.WriteFile(path, data, 0o600); err != nil {
72- t.Fatal(err)
73- }
74-}
75-
76-func closeStore(t *testing.T, s *Store) {
77- t.Helper()
78- if err := s.Close(); err != nil {
79- t.Fatal(err)
80- }
81-}
82-
83-func TestSetGetDeleteRange(t *testing.T) {
84- path := privatePath(t)
85- s, err := Open(path)
86- if err != nil {
87- t.Fatal(err)
88- }
89- defer closeStore(t, s)
90-
91- if err := s.Set([]byte("b"), []byte("two")); err != nil {
92- t.Fatal(err)
93- }
94- if err := s.Set([]byte("a"), []byte("one")); err != nil {
95- t.Fatal(err)
96- }
97- got, ok := s.Get([]byte("a"))
98- if !ok || string(got) != "one" {
99- t.Fatalf("Get(a) = %q, %v", got, ok)
100- }
101- got[0] = 'X'
102- got, _ = s.Get([]byte("a"))
103- if string(got) != "one" {
104- t.Fatalf("Get returned mutable value: %q", got)
105- }
106- if err := s.Delete([]byte("b")); err != nil {
107- t.Fatal(err)
108- }
109- if _, ok := s.Get([]byte("b")); ok {
110- t.Fatal("deleted key still present")
111- }
112-
113- seen := map[string]string{}
114- s.Range(func(k, v []byte) bool {
115- seen[string(k)] = string(v)
116- return true
117- })
118- if len(seen) != 1 || seen["a"] != "one" {
119- t.Fatalf("Range saw %#v", seen)
120- }
121-}
122-
123-func TestReopenDurability(t *testing.T) {
124- path := privatePath(t)
125- s, err := Open(path)
126- if err != nil {
127- t.Fatal(err)
128- }
129- if err := s.Set([]byte("k"), []byte("v")); err != nil {
130- t.Fatal(err)
131- }
132- if err := s.Close(); err != nil {
133- t.Fatal(err)
134- }
135-
136- s, err = Open(path)
137- if err != nil {
138- t.Fatal(err)
139- }
140- defer closeStore(t, s)
141- got, ok := s.Get([]byte("k"))
142- if !ok || string(got) != "v" {
143- t.Fatalf("after reopen Get(k) = %q, %v", got, ok)
144- }
145-}
146-
147-func TestCompactionShrinksWithDataIntact(t *testing.T) {
148- path := privatePath(t)
149- s, err := Open(path)
150- if err != nil {
151- t.Fatal(err)
152- }
153- if err := s.Set([]byte("keep"), bytes.Repeat([]byte("x"), 128)); err != nil {
154- t.Fatal(err)
155- }
156- info, err := os.Stat(path)
157- if err != nil {
158- t.Fatal(err)
159- }
160- before := info.Size()
161- if err := s.Set([]byte("keep"), []byte("y")); err != nil {
162- t.Fatal(err)
163- }
164- info, err = os.Stat(path)
165- if err != nil {
166- t.Fatal(err)
167- }
168- after := info.Size()
169- if after >= before {
170- t.Fatalf("expected compaction to shrink file: before=%d after=%d", before, after)
171- }
172- got, ok := s.Get([]byte("keep"))
173- if !ok || string(got) != "y" {
174- t.Fatalf("compacted value = %q, %v", got, ok)
175- }
176- if err := s.Close(); err != nil {
177- t.Fatal(err)
178- }
179-}
180-
181-func TestOpenTruncatesMidRecordTail(t *testing.T) {
182- path := privatePath(t)
183- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
184- second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
185- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
186- t.Fatal(err)
187- }
188- if err := os.WriteFile(path, currentFile(first, second[:len(second)/2]), 0o600); err != nil {
189- t.Fatal(err)
190- }
191-
192- s, err := Open(path)
193- if err != nil {
194- t.Fatal(err)
195- }
196- defer closeStore(t, s)
197- if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
198- t.Fatalf("good record missing: %q %v", got, ok)
199- }
200- if _, ok := s.Get([]byte("b")); ok {
201- t.Fatal("torn record was replayed")
202- }
203- info, err := os.Stat(path)
204- if err != nil {
205- t.Fatal(err)
206- }
207- if want := int64(fileHeaderLen + len(first)); info.Size() != want {
208- t.Fatalf("tail not truncated: size=%d want=%d", info.Size(), want)
209- }
210-}
211-
212-func TestCRCcorruptionOfLastRecordFailsClosed(t *testing.T) {
213- path := privatePath(t)
214- first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
215- second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
216- buf := currentFile(first, second)
217- buf[fileHeaderLen+len(first)+recordHeaderLen+payloadPrefixLen+1] ^= 0xff
218- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
219- t.Fatal(err)
220- }
221- if err := os.WriteFile(path, buf, 0o600); err != nil {
222- t.Fatal(err)
223- }
224-
225- _, err := Open(path)
226- if !errors.Is(err, ErrCorruptWAL) {
227- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
228- }
229- info, statErr := os.Stat(path)
230- if statErr != nil {
231- t.Fatal(statErr)
232- }
233- if info.Size() != int64(len(buf)) {
234- t.Fatalf("corrupt WAL was mutated: size=%d want=%d", info.Size(), len(buf))
235- }
236-}
237-
238-func TestAbsentAndEmptyFile(t *testing.T) {
239- path := filepath.Join(t.TempDir(), "missing", "store.kv")
240- m, err := Replay(path)
241- if err != nil {
242- t.Fatal(err)
243- }
244- if len(m) != 0 {
245- t.Fatalf("Replay(absent) = %#v", m)
246- }
247- s, err := Open(path)
248- if err != nil {
249- t.Fatal(err)
250- }
251- if err := s.Close(); err != nil {
252- t.Fatal(err)
253- }
254- m, err = Replay(path)
255- if err != nil {
256- t.Fatal(err)
257- }
258- if len(m) != 0 {
259- t.Fatalf("Replay(empty) = %#v", m)
260- }
261-}
262-
263-func TestTombstoneSurvivesCompaction(t *testing.T) {
264- path := privatePath(t)
265- s, err := Open(path)
266- if err != nil {
267- t.Fatal(err)
268- }
269- if err := s.Set([]byte("dead"), bytes.Repeat([]byte("d"), 128)); err != nil {
270- t.Fatal(err)
271- }
272- if err := s.Set([]byte("keep"), bytes.Repeat([]byte("k"), 128)); err != nil {
273- t.Fatal(err)
274- }
275- if err := s.Delete([]byte("dead")); err != nil {
276- t.Fatal(err)
277- }
278- if err := s.Close(); err != nil {
279- t.Fatal(err)
280- }
281-
282- s, err = Open(path)
283- if err != nil {
284- t.Fatal(err)
285- }
286- defer closeStore(t, s)
287- if _, ok := s.Get([]byte("dead")); ok {
288- t.Fatal("deleted key resurrected after compaction/reopen")
289- }
290- if got, ok := s.Get([]byte("keep")); !ok || len(got) != 128 || got[0] != 'k' {
291- t.Fatalf("live key missing after compaction/reopen: len=%d ok=%v", len(got), ok)
292- }
293-}
294-
295-func TestReplayStrict(t *testing.T) {
296- valid, err := encodeRecord(opSet, []byte("old"), []byte("value"))
297- if err != nil {
298- t.Fatal(err)
299- }
300- batch, err := encodeBatch([]BatchChange{{Key: []byte("old"), Delete: true}, {Key: []byte("new"), Value: []byte("value")}})
301- if err != nil {
302- t.Fatal(err)
303- }
304-
305- tests := []struct {
306- name string
307- wal []byte
308- torn bool
309- corrupt bool
310- }{
311- {name: "torn-final-header", wal: currentFile(valid, batch[:recordHeaderLen-1]), torn: true},
312- {name: "torn-final-payload", wal: currentFile(valid, batch[:len(batch)-1]), torn: true},
313- {name: "final-crc", wal: corruptRecord(currentFile(valid, batch), fileHeaderLen+len(valid)+recordHeaderLen), corrupt: true},
314- {name: "middle-crc", wal: corruptRecord(currentFile(valid, batch), fileHeaderLen+recordHeaderLen), corrupt: true},
315- {name: "invalid-op", wal: currentFile(rawRecord([]byte{0xff, 0, 0})), corrupt: true},
316- {name: "impossible-batch-count", wal: currentFile(rawRecord([]byte{opBatch, 0xff, 0xff, 0xff, 0xff})), corrupt: true},
317- {name: "duplicate-key-in-batch", wal: currentFile(rawRecord(rawBatchPayload([]BatchChange{{Key: []byte("x")}, {Key: []byte("x"), Delete: true}}))), corrupt: true},
318- {name: "trailing-batch-garbage", wal: currentFile(rawRecord(append(rawBatchPayload([]BatchChange{{Key: []byte("x")}}), 0))), corrupt: true},
319- }
320- for _, tt := range tests {
321- t.Run(tt.name, func(t *testing.T) {
322- path := privatePath(t)
323- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
324- t.Fatal(err)
325- }
326- if err := os.WriteFile(path, tt.wal, 0o600); err != nil {
327- t.Fatal(err)
328- }
329- f, err := os.Open(path)
330- if err != nil {
331- t.Fatal(err)
332- }
333- result, replayErr := replayFile(f)
334- _ = f.Close()
335- if tt.corrupt {
336- if !errors.Is(replayErr, ErrCorruptWAL) {
337- t.Fatalf("replay error = %v, want ErrCorruptWAL", replayErr)
338- }
339- return
340- }
341- if replayErr != nil {
342- t.Fatal(replayErr)
343- }
344- if string(result.Data["old"]) != "value" || result.Data["new"] != nil {
345- t.Fatalf("torn batch was partially applied: %#v", result.Data)
346- }
347- if !result.TornTail || result.LastGood != int64(fileHeaderLen+len(valid)) {
348- t.Fatalf("result = %+v", result)
349- }
350- })
351- }
352-}
353-
354-// TestRecoverCompactionCleansStaleNextRegardlessOfPrev covers the branch
355-// where current is valid: any stale .next must be removed even when a .prev
356-// also exists (previously the "nextExists && !prevExists" guard left a .next
357-// behind whenever .prev was present too, so spec §3's complete-cleanup
358-// requirement was not met after certain crash-then-retry sequences).
359-func TestRecoverCompactionCleansStaleNextRegardlessOfPrev(t *testing.T) {
360- oldRecord, _ := encodeRecord(opSet, []byte("version"), []byte("old"))
361- newRecord, _ := encodeRecord(opSet, []byte("version"), []byte("new"))
362- oldFile, newFile := currentFile(oldRecord), currentFile(newRecord)
363- tests := []struct {
364- name string
365- current, next, prev []byte
366- }{
367- {name: "current-next-only", current: oldFile, next: newFile},
368- {name: "current-next-and-prev", current: oldFile, next: newFile, prev: oldFile},
369- }
370- for _, tt := range tests {
371- t.Run(tt.name, func(t *testing.T) {
372- path := privatePath(t)
373- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
374- t.Fatal(err)
375- }
376- for suffix, data := range map[string][]byte{"": tt.current, ".next": tt.next, ".prev": tt.prev} {
377- if data != nil {
378- if err := os.WriteFile(path+suffix, data, 0o600); err != nil {
379- t.Fatal(err)
380- }
381- }
382- }
383- if err := recoverCompaction(path, defaultFSHooks()); err != nil {
384- t.Fatal(err)
385- }
386- if _, err := os.Stat(path + ".next"); !errors.Is(err, os.ErrNotExist) {
387- t.Fatalf(".next survived recovery: err = %v", err)
388- }
389- if _, err := os.Stat(path + ".prev"); !errors.Is(err, os.ErrNotExist) {
390- t.Fatalf(".prev survived recovery: err = %v", err)
391- }
392- got, err := Replay(path)
393- if err != nil {
394- t.Fatal(err)
395- }
396- if string(got["version"]) != "old" {
397- t.Fatalf("version = %q, want %q (current must remain authoritative)", got["version"], "old")
398- }
399- })
400- }
401-}
402-
403-func TestReplayPreservesWrappedDecodeCause(t *testing.T) {
404- path := privatePath(t)
405- writeFile(t, path, currentFile(rawRecord([]byte{0xff, 0, 0})))
406- f, err := os.Open(path)
407- if err != nil {
408- t.Fatal(err)
409- }
410- defer func() { _ = f.Close() }()
411- _, err = replayFile(f)
412- if !errors.Is(err, ErrCorruptWAL) || !errors.Is(err, errBadRecord) {
413- t.Fatalf("replayFile error = %v, want wrapped ErrCorruptWAL and errBadRecord", err)
414- }
415-}
416-
417-func TestBatchLiveSizeAccountsForRemainderExactly(t *testing.T) {
418- data := map[string][]byte{}
419- sizes := map[string]int64{}
420- var live int64
421- applyRecords(data, sizes, &live, []record{
422- {op: opSet, key: []byte("a"), value: []byte("one")},
423- {op: opSet, key: []byte("b"), value: []byte("two")},
424- {op: opSet, key: []byte("c"), value: []byte("three")},
425- }, 10)
426- if live != 10 || sizes["a"]+sizes["b"]+sizes["c"] != 10 {
427- t.Fatalf("live sizes = %v, live = %d, want exact total 10", sizes, live)
428- }
429-}
430-
431-func TestDecodeBatchRejectsImpossibleCountBeforeAllocating(t *testing.T) {
432- payload := []byte{opBatch, 0xff, 0xff, 0xff, 0xff}
433- allocs := testing.AllocsPerRun(10, func() {
434- if _, err := decodeBatch(payload); !errors.Is(err, errBadRecord) {
435- t.Fatalf("decodeBatch error = %v, want errBadRecord", err)
436- }
437- })
438- if allocs != 0 {
439- t.Fatalf("decodeBatch allocations = %v, want 0", allocs)
440- }
441-}
442-
443-func TestBatchAtomicity(t *testing.T) {
444- path := privatePath(t)
445- s, err := Open(path)
446- if err != nil {
447- t.Fatal(err)
448- }
449- defer closeStore(t, s)
450- if err := s.Set([]byte("old"), []byte("before")); err != nil {
451- t.Fatal(err)
452- }
453- if err := s.Batch(nil); err == nil {
454- t.Fatal("empty batch succeeded")
455- }
456- if err := s.Batch([]BatchChange{{Key: []byte("duplicate")}, {Key: []byte("duplicate"), Delete: true}}); err == nil {
457- t.Fatal("duplicate-key batch succeeded")
458- }
459- if err := s.Batch([]BatchChange{{Key: []byte("old"), Delete: true}, {Key: []byte("new"), Value: []byte("same-incarnation")}}); err != nil {
460- t.Fatal(err)
461- }
462- if err := s.Sync(); err != nil {
463- t.Fatal(err)
464- }
465- if _, ok := s.Get([]byte("old")); ok {
466- t.Fatal("old key remains")
467- }
468- got, ok := s.Get([]byte("new"))
469- if !ok || !bytes.Equal(got, []byte("same-incarnation")) {
470- t.Fatalf("new = %q, %v", got, ok)
471- }
472-}
473-
474-func TestCompactionRecoveryMatrix(t *testing.T) {
475- oldRecord, _ := encodeRecord(opSet, []byte("version"), []byte("old"))
476- newRecord, _ := encodeRecord(opSet, []byte("version"), []byte("new"))
477- oldFile, newFile := currentFile(oldRecord), currentFile(newRecord)
478- tests := []struct {
479- name string
480- current, next, prev []byte
481- want string
482- wantErr bool
483- }{
484- {name: "current-alone", current: oldFile, want: "old"},
485- {name: "current-next", current: oldFile, next: newFile, want: "old"},
486- {name: "prev-next", prev: oldFile, next: newFile, want: "new"},
487- {name: "next-alone", next: newFile, want: "new"},
488- {name: "invalid-prev-valid-next", prev: currentFile(rawRecord([]byte{0xff, 0, 0})), next: newFile, want: "new"},
489- {name: "valid-prev-invalid-next", prev: oldFile, next: currentFile(rawRecord([]byte{0xff, 0, 0})), want: "old"},
490- {name: "prev-torn-next", prev: oldFile, next: newFile[:len(newFile)-1], want: "old"},
491- {name: "current-prev", current: newFile, prev: oldFile, want: "new"},
492- {name: "invalid-current-prev", current: currentFile(rawRecord([]byte{0xff, 0, 0})), prev: oldFile, wantErr: true},
493- {name: "prev-alone", prev: oldFile, want: "old"},
494- }
495- for _, tt := range tests {
496- t.Run(tt.name, func(t *testing.T) {
497- path := privatePath(t)
498- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
499- t.Fatal(err)
500- }
501- for suffix, data := range map[string][]byte{"": tt.current, ".next": tt.next, ".prev": tt.prev} {
502- if data != nil {
503- if err := os.WriteFile(path+suffix, data, 0o600); err != nil {
504- t.Fatal(err)
505- }
506- }
507- }
508- before := candidateBytes(t, path)
509- err := recoverCompaction(path, defaultFSHooks())
510- if tt.wantErr {
511- if !errors.Is(err, ErrCorruptWAL) {
512- t.Fatalf("recoverCompaction error = %v, want ErrCorruptWAL", err)
513- }
514- if got := candidateBytes(t, path); !reflect.DeepEqual(got, before) {
515- t.Fatalf("uncertain candidates changed: got %#v, want %#v", got, before)
516- }
517- return
518- }
519- if err != nil {
520- t.Fatal(err)
521- }
522- got, err := Replay(path)
523- if err != nil {
524- t.Fatal(err)
525- }
526- if string(got["version"]) != tt.want {
527- t.Fatalf("version = %q, want %q", got["version"], tt.want)
528- }
529- })
530- }
531-
532- for _, point := range compactionFaultPoints {
533- t.Run("fault-"+point, func(t *testing.T) {
534- path := privatePath(t)
535- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
536- t.Fatal(err)
537- }
538- if err := os.WriteFile(path, oldFile, 0o600); err != nil {
539- t.Fatal(err)
540- }
541- s, err := Open(path)
542- if err != nil {
543- t.Fatal(err)
544- }
545- s.data = map[string][]byte{"version": []byte("new")}
546- s.hooks.Fault = func(got string) error {
547- if got == point {
548- return errors.New("injected " + point)
549- }
550- return nil
551- }
552- if err := s.compactLocked(); err == nil {
553- t.Fatalf("fault %q did not interrupt compaction", point)
554- }
555- beforeAppend := candidateBytes(t, path)
556- if err := s.Set([]byte("later"), []byte("must-not-append")); !errors.Is(err, os.ErrClosed) {
557- t.Fatalf("append after compaction fault = %v, want closed store", err)
558- }
559- requireCandidateBytes(t, path, beforeAppend)
560- if err := recoverCompaction(path, defaultFSHooks()); err != nil {
561- t.Fatalf("recovery: %v", err)
562- }
563- got, err := Replay(path)
564- if err != nil {
565- t.Fatal(err)
566- }
567- version := string(got["version"])
568- if (version != "old" && version != "new") || len(got) != 1 {
569- t.Fatalf("mixed or empty recovered map: %#v", got)
570- }
571- })
572- }
573-}
574-
575-func TestCompactionFilesystemOperationFailuresPoisonAndRecover(t *testing.T) {
576- fault := errors.New("injected filesystem failure")
577 tests := []struct {15 tests := []struct {
578 name string16 name string
579- configure func(fsHooks, string) fsHooks17+ leaveTmp bool
18+ corrupt bool
19+ wantOpen bool
20+ wantValue string
580 }{21 }{
581- {name: "write-next", configure: func(h fsHooks, _ string) fsHooks {22+ {name: "clean file opens", wantOpen: true, wantValue: "v1"},
582- h.Write = func(*os.File, []byte) (int, error) { return 0, fault }23+ {name: "stray tmp is ignored", leaveTmp: true, wantOpen: true, wantValue: "v1"},
583- return h24+ {name: "corrupt file fails closed", corrupt: true},
584- }},
585- {name: "sync-next", configure: func(h fsHooks, _ string) fsHooks {
586- h.Sync = func(*os.File) error { return fault }
587- return h
588- }},
589- {name: "close-next", configure: func(h fsHooks, _ string) fsHooks {
590- calls := 0
591- h.Close = func(f *os.File) error {
592- calls++
593- if calls == 1 {
594- return errors.Join(f.Close(), fault)
595- }
596- return f.Close()
597- }
598- return h
599- }},
600- {name: "remove-stale-prev", configure: failRemoveCall(fault, 1)},
601- {name: "rename-current-prev", configure: failRenameCall(fault, 1)},
602- {name: "sync-dir-next", configure: failSyncDirCall(fault, 1)},
603- {name: "sync-dir-prev", configure: failSyncDirCall(fault, 2)},
604- {name: "rename-next-current", configure: failRenameCall(fault, 2)},
605- {name: "sync-dir-current", configure: failSyncDirCall(fault, 3)},
606- {name: "reopen-current", configure: func(h fsHooks, path string) fsHooks {
607- open := h.Open
608- h.Open = func(name string, flags int, mode os.FileMode) (*os.File, error) {
609- if name == path && flags == os.O_RDWR {
610- return nil, fault
611- }
612- return open(name, flags, mode)
613- }
614- return h
615- }},
616- {name: "close-old-current", configure: func(h fsHooks, _ string) fsHooks {
617- calls := 0
618- h.Close = func(f *os.File) error {
619- calls++
620- if calls == 2 {
621- return errors.Join(f.Close(), fault)
622- }
623- return f.Close()
624- }
625- return h
626- }},
627- {name: "remove-prev", configure: failRemoveCall(fault, 2)},
628- {name: "sync-dir-final", configure: failSyncDirCall(fault, 4)},
629 }25 }
630- oldRecord, err := encodeRecord(opSet, []byte("version"), []byte("old"))
631- if err != nil {
632- t.Fatal(err)
633- }
634- oldFile := currentFile(oldRecord)
635 for _, tt := range tests {26 for _, tt := range tests {
636 t.Run(tt.name, func(t *testing.T) {27 t.Run(tt.name, func(t *testing.T) {
637- path := privatePath(t)28+ t.Parallel()
638- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {29+ path := privateStorePath(t)
639- t.Fatal(err)30+ store, err := Open(path)
640- }31+ require.NoError(t, err)
641- if err := os.WriteFile(path, oldFile, 0o600); err != nil {32+ require.NoError(t, store.Set([]byte("k"), []byte("v1")))
642- t.Fatal(err)33+ require.NoError(t, store.Sync())
643- }34+ require.NoError(t, store.Close())
644- s, err := Open(path)
645- if err != nil {
646- t.Fatal(err)
647- }
648- s.data = map[string][]byte{"version": []byte("new")}
649- s.hooks = tt.configure(s.hooks, path)
650 35
651- err = s.compactLocked()36+ if tt.leaveTmp {
652- if !errors.Is(err, fault) {37+ require.NoError(t, os.WriteFile(path+".tmp", []byte("half written"), 0o600))
653- t.Fatalf("compaction error = %v, want injected fault", err)
654 }38 }
655- beforeAppend := candidateBytes(t, path)39+ if tt.corrupt {
656- if err := s.Set([]byte("later"), []byte("must-not-append")); !errors.Is(err, os.ErrClosed) {40+ data, err := os.ReadFile(path)
657- t.Fatalf("append after compaction fault = %v, want closed store", err)41+ require.NoError(t, err)
42+ data[len(data)-1] ^= 0xff
43+ require.NoError(t, os.WriteFile(path, data, 0o600))
658 }44 }
659- requireCandidateBytes(t, path, beforeAppend)
660 45
661- if err := recoverCompaction(path, defaultFSHooks()); err != nil {46+ reopened, err := Open(path)
662- t.Fatalf("recovery: %v", err)47+ if !tt.wantOpen {
663- }48+ require.ErrorIs(t, err, ErrCorrupt)
664- got, err := Replay(path)49+ return
665- if err != nil {
666- t.Fatal(err)
667- }
668- version := string(got["version"])
669- if (version != "old" && version != "new") || len(got) != 1 {
670- t.Fatalf("mixed or empty recovered map: %#v", got)
671 }50 }
51+ require.NoError(t, err)
52+ t.Cleanup(func() { _ = reopened.Close() })
53+ got, ok := reopened.Get([]byte("k"))
54+ require.True(t, ok)
55+ require.Equal(t, tt.wantValue, string(got))
672 })56 })
673 }57 }
674 }58 }
675 59
676-func failRemoveCall(fault error, target int) func(fsHooks, string) fsHooks {60+func TestSyncIsAtomicAcrossKeys(t *testing.T) {
677- return func(h fsHooks, _ string) fsHooks {61+ t.Parallel()
678- remove := h.Remove62+ path := privateStorePath(t)
679- calls := 063+ store, err := Open(path)
680- h.Remove = func(path string) error {64+ require.NoError(t, err)
681- calls++65+ require.NoError(t, store.Set([]byte("old"), []byte("x")))
682- if calls == target {66+ require.NoError(t, store.Sync())
683- return fault67+ require.NoError(t, store.Delete([]byte("old")))
684- }68+ require.NoError(t, store.Set([]byte("new"), []byte("x")))
685- return remove(path)69+ require.NoError(t, store.Sync())
686- }70+ require.NoError(t, store.Close())
687- return h
688- }
689-}
690-
691-func failRenameCall(fault error, target int) func(fsHooks, string) fsHooks {
692- return func(h fsHooks, _ string) fsHooks {
693- rename := h.Rename
694- calls := 0
695- h.Rename = func(oldPath, newPath string) error {
696- calls++
697- if calls == target {
698- return fault
699- }
700- return rename(oldPath, newPath)
701- }
702- return h
703- }
704-}
705-
706-func failSyncDirCall(fault error, target int) func(fsHooks, string) fsHooks {
707- return func(h fsHooks, _ string) fsHooks {
708- sync := h.SyncDir
709- calls := 0
710- h.SyncDir = func(path string) error {
711- calls++
712- if calls == target {
713- return fault
714- }
715- return sync(path)
716- }
717- return h
718- }
719-}
720 71
721-func candidateBytes(t *testing.T, path string) map[string][]byte {72+ reopened, err := Open(path)
722- t.Helper()73+ require.NoError(t, err)
723- got := make(map[string][]byte, 3)74+ t.Cleanup(func() { _ = reopened.Close() })
724- for _, suffix := range []string{"", ".next", ".prev"} {75+ _, hadOld := reopened.Get([]byte("old"))
725- data, err := os.ReadFile(path + suffix)76+ _, hasNew := reopened.Get([]byte("new"))
726- if errors.Is(err, os.ErrNotExist) {77+ require.False(t, hadOld)
727- continue78+ require.True(t, hasNew)
728- }
729- if err != nil {
730- t.Fatal(err)
731- }
732- got[suffix] = data
733- }
734- return got
735-}
736-
737-func requireCandidateBytes(t *testing.T, path string, want map[string][]byte) {
738- t.Helper()
739- got := candidateBytes(t, path)
740- if !reflect.DeepEqual(got, want) {
741- t.Fatalf("catalogue candidates changed after poisoned append: got %#v, want %#v", got, want)
742- }
743-}
744-
745-func corruptRecord(buf []byte, offset int) []byte { buf[offset] ^= 0xff; return buf }
746-func rawRecord(payload []byte) []byte { return framePayload(payload) }
747-func rawBatchPayload(changes []BatchChange) []byte {
748- payload := []byte{opBatch, 0, 0, 0, byte(len(changes))}
749- for _, change := range changes {
750- op := opSet
751- if change.Delete {
752- op = opDel
753- }
754- payload = append(payload, op, 0, byte(len(change.Key)))
755- payload = append(payload, change.Key...)
756- payload = binary.BigEndian.AppendUint32(payload, uint32(len(change.Value)))
757- payload = append(payload, change.Value...)
758- }
759- return payload
760 }79 }
761 80
762-// An oversized length must never drive an allocation. Legacy framing is81+func TestOpenFailsClosedWithoutMutation(t *testing.T) {
763-// accepted only after complete strict replay, while the current header checksum82+ t.Parallel()
764-// proves an oversized length was written that way; both fail closed.
765-func TestCorruptedCurrentHeaderNeverFallsBackToLegacyOrDeletesPredecessor(t *testing.T) {
766- record, _ := encodeRecord(opSet, []byte("version"), []byte("current"))
767- previous, _ := encodeRecord(opSet, []byte("version"), []byte("previous"))
768-
769 tests := []struct {83 tests := []struct {
770- name string84+ name string
771- current []byte85+ raw []byte
772- standalone bool
773 }{86 }{
774- {name: "torn file header", current: append([]byte(nil), fileHeader()[:fileHeaderLen-1]...), standalone: true},87+ {name: "empty", raw: []byte{}},
775- {name: "torn current record", current: currentFile(record)[:fileHeaderLen+recordHeaderLen+1]},88+ {name: "short magic", raw: []byte("VE")},
776- }89+ {name: "unknown magic", raw: []byte("NOPE\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00")},
777- for i := range fileMagicLen {90+ {name: "unknown version", raw: func() []byte { b := validEmptyFile(); binary.BigEndian.PutUint16(b[4:6], 99); return b }()},
778- corrupted := currentFile(record)91+ {name: "truncated entry", raw: append(validEmptyFile()[:10], 0, 0, 0, 1)},
779- corrupted[i] = 0
780- tests = append(tests, struct {
781- name string
782- current []byte
783- standalone bool
784- }{name: fmt.Sprintf("corrupted magic byte %d", i), current: corrupted, standalone: true})
785 }92 }
786- for length := 1; length < fileHeaderLen; length++ {
787- tests = append(tests, struct {
788- name string
789- current []byte
790- standalone bool
791- }{name: fmt.Sprintf("torn header prefix %d", length), current: append([]byte(nil), fileHeader()[:length]...), standalone: true})
792- }
793-
794 for _, tt := range tests {93 for _, tt := range tests {
795 t.Run(tt.name, func(t *testing.T) {94 t.Run(tt.name, func(t *testing.T) {
796- for _, withCandidates := range []bool{false, true} {95+ t.Parallel()
797- if !withCandidates && !tt.standalone {96+ path := privateStorePath(t)
798- continue97+ require.NoError(t, os.WriteFile(path, tt.raw, 0o600))
799- }98+ _, err := Open(path)
800- name := "standalone"99+ require.ErrorIs(t, err, ErrCorrupt)
801- if withCandidates {100+ after, readErr := os.ReadFile(path)
802- name = "with candidates"101+ require.NoError(t, readErr)
803- }102+ require.Equal(t, tt.raw, after)
804- t.Run(name, func(t *testing.T) {
805- path := privatePath(t)
806- writeFile(t, path, tt.current)
807- if withCandidates {
808- if err := os.WriteFile(path+".prev", currentFile(previous), 0o600); err != nil {
809- t.Fatal(err)
810- }
811- if err := os.WriteFile(path+".next", currentFile(record), 0o600); err != nil {
812- t.Fatal(err)
813- }
814- }
815- before := candidateBytes(t, path)
816- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
817- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
818- }
819- if got := candidateBytes(t, path); !reflect.DeepEqual(got, before) {
820- t.Fatalf("candidates changed: got %#v, want %#v", got, before)
821- }
822- })
823- }
824 })103 })
825 }104 }
826 }105 }
827 106
828-func TestReplayRejectsOversizedLengthWithoutAllocating(t *testing.T) {107+func TestCloseSyncsDirtyStore(t *testing.T) {
829- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))108+ t.Parallel()
830- legacyHeader := make([]byte, legacyHeaderLen)109+ path := privateStorePath(t)
831- binary.BigEndian.PutUint32(legacyHeader[0:4], 1<<31)110+ store, err := Open(path)
832-111+ require.NoError(t, err)
833- t.Run("legacy incomplete record fails closed", func(t *testing.T) {112+ require.NoError(t, store.Set([]byte("k"), []byte("v")))
834- path := privatePath(t)113+ require.NoError(t, store.Close())
835- wal := append(legacyFile(first), legacyHeader...)114+
836- writeFile(t, path, wal)115+ reopened, err := Open(path)
837- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {116+ require.NoError(t, err)
838- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)117+ t.Cleanup(func() { _ = reopened.Close() })
839- }118+ got, ok := reopened.Get([]byte("k"))
840- requireFileBytes(t, path, wal)119+ require.True(t, ok)
841- })120+ require.Equal(t, []byte("v"), got)
842-121+}
843- t.Run("current oversized length fails closed", func(t *testing.T) {122+
844- path := privatePath(t)123+func TestStoreCopiesValuesAndRangesInKeyOrder(t *testing.T) {
845- wal := currentFile(first, oversizedRecordHeader(maxPayloadLen+1))124+ t.Parallel()
846- writeFile(t, path, wal)125+ store, err := Open(privateStorePath(t))
847- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {126+ require.NoError(t, err)
848- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)127+ t.Cleanup(func() { _ = store.Close() })
849- }128+ value := []byte("two")
850- requireFileBytes(t, path, wal)129+ require.NoError(t, store.Set([]byte("b"), value))
130+ value[0] = 'x'
131+ require.NoError(t, store.Set([]byte("a"), []byte("one")))
132+ got, ok := store.Get([]byte("b"))
133+ require.True(t, ok)
134+ require.Equal(t, []byte("two"), got)
135+ got[0] = 'x'
136+ gotAgain, _ := store.Get([]byte("b"))
137+ require.Equal(t, []byte("two"), gotAgain)
138+
139+ var keys []string
140+ store.Range(func(k, v []byte) bool {
141+ keys = append(keys, string(k))
142+ return true
851 })143 })
144+ require.Equal(t, []string{"a", "b"}, keys)
852 }145 }
853 146
854-// oversizedRecordHeader is a record header whose own checksum is valid, so the147+func TestStoreLockAndClosedErrors(t *testing.T) {
855-// length is provably what the writer put there rather than a bit flip.148+ t.Parallel()
856-func oversizedRecordHeader(payloadLen uint32) []byte {149+ path := privateStorePath(t)
857- header := make([]byte, recordHeaderLen)150+ store, err := Open(path)
858- binary.BigEndian.PutUint32(header[0:4], payloadLen)151+ require.NoError(t, err)
859- binary.BigEndian.PutUint32(header[8:recordHeaderLen], crc32.ChecksumIEEE(header[0:8]))152+ _, err = Open(path)
860- return header153+ require.Error(t, err)
154+ require.NoError(t, store.Close())
155+ require.NoError(t, store.Close())
156+ require.ErrorIs(t, store.Set([]byte("k"), []byte("v")), os.ErrClosed)
157+ require.ErrorIs(t, store.Delete([]byte("k")), os.ErrClosed)
158+ require.ErrorIs(t, store.Sync(), os.ErrClosed)
861 }159 }
862 160
863-func requireFileBytes(t *testing.T, path string, want []byte) {161+func privateStorePath(t *testing.T) string {
864 t.Helper()162 t.Helper()
865- got, err := os.ReadFile(path)163+ dir := filepath.Join(t.TempDir(), "vev")
866- if err != nil {164+ require.NoError(t, os.Mkdir(dir, 0o700))
867- t.Fatal(err)165+ return filepath.Join(dir, "sessions.kv")
868- }166+}
869- if !bytes.Equal(got, want) {167+
870- t.Fatalf("file was mutated: got %d bytes, want %d", len(got), len(want))168+func validEmptyFile() []byte {
871- }169+ return encodeFile(map[string][]byte{})
872-}170+}
873-171+
874-// The regression this format exists for: a bit flip in the length field of an172+func TestDecodeRejectsDuplicateKeysAndTrailingData(t *testing.T) {
875-// early record used to look exactly like a torn tail, so Open truncated every173+ t.Parallel()
876-// record written after it and reopened a smaller, structurally valid file.174+ payload := make([]byte, 0)
877-func TestCorruptLengthOfEarlyRecordFailsClosedWithoutTruncating(t *testing.T) {175+ payload = append(payload, fileMagic[:]...)
878- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))176+ payload = binary.BigEndian.AppendUint16(payload, fileVersion)
879- second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))177+ payload = binary.BigEndian.AppendUint32(payload, 2)
880- third, _ := encodeRecord(opSet, []byte("c"), []byte("three"))178+ for range 2 {
881-179+ payload = binary.BigEndian.AppendUint32(payload, 1)
882- tests := []struct {180+ payload = append(payload, 'k')
883- name string181+ payload = binary.BigEndian.AppendUint32(payload, 1)
884- offset int182+ payload = append(payload, 'v')
885- }{183+ }
886- {name: "first-record-length", offset: fileHeaderLen},184+ raw := appendCRC(payload)
887- {name: "second-record-length", offset: fileHeaderLen + len(first) + 3},185+ _, err := decodeFile(raw)
888- }186+ require.ErrorIs(t, err, ErrCorrupt)
889- for _, tt := range tests {187+
890- t.Run(tt.name, func(t *testing.T) {188+ valid := validEmptyFile()
891- path := privatePath(t)189+ valid = append(valid, 0)
892- wal := currentFile(first, second, third)190+ _, err = decodeFile(valid)
893- // Flip a high bit so the length claims far more than the file holds:191+ require.True(t, errors.Is(err, ErrCorrupt))
894- // the exact shape that used to be misread as an incomplete tail.
895- wal[tt.offset] ^= 0x40
896- writeFile(t, path, wal)
897-
898- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
899- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
900- }
901- requireFileBytes(t, path, wal)
902- if _, err := Replay(path); !errors.Is(err, ErrCorruptWAL) {
903- t.Fatalf("Replay error = %v, want ErrCorruptWAL", err)
904- }
905- })
906- }
907-}
908-
909-// Every row of the replay decision table for the current format. A torn tail is
910-// the only truncatable outcome.
911-func TestCurrentFormatReplayDecisionTable(t *testing.T) {
912- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
913- second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
914- invalidOp := rawRecord([]byte{0xff, 0, 0})
915-
916- tests := []struct {
917- name string
918- wal []byte
919- torn bool
920- corrupt bool
921- }{
922- {name: "partial-header", wal: currentFile(first, second[:recordHeaderLen-1]), torn: true},
923- {name: "header-checksum-mismatch", wal: corruptRecord(currentFile(first, second), fileHeaderLen+len(first)+1), corrupt: true},
924- {name: "short-payload", wal: currentFile(first, second[:len(second)-1]), torn: true},
925- {name: "payload-checksum-mismatch", wal: corruptRecord(currentFile(first, second), fileHeaderLen+len(first)+recordHeaderLen), corrupt: true},
926- {name: "undecodable-payload", wal: currentFile(first, invalidOp), corrupt: true},
927- {name: "oversized-length", wal: currentFile(first, oversizedRecordHeader(maxPayloadLen+1)), corrupt: true},
928- {name: "complete-records", wal: currentFile(first, second)},
929- }
930- for _, tt := range tests {
931- t.Run(tt.name, func(t *testing.T) {
932- path := privatePath(t)
933- writeFile(t, path, tt.wal)
934- f, err := os.Open(path)
935- if err != nil {
936- t.Fatal(err)
937- }
938- result, err := replayFile(f)
939- _ = f.Close()
940-
941- if tt.corrupt {
942- if !errors.Is(err, ErrCorruptWAL) {
943- t.Fatalf("replay error = %v, want ErrCorruptWAL", err)
944- }
945- return
946- }
947- if err != nil {
948- t.Fatal(err)
949- }
950- if result.Format != formatCurrent {
951- t.Fatalf("format = %d, want current", result.Format)
952- }
953- if result.TornTail != tt.torn {
954- t.Fatalf("TornTail = %v, want %v", result.TornTail, tt.torn)
955- }
956- if string(result.Data["a"]) != "one" {
957- t.Fatalf("prefix lost: %#v", result.Data)
958- }
959- wantLastGood := int64(fileHeaderLen + len(first))
960- if !tt.torn {
961- wantLastGood += int64(len(second))
962- }
963- if result.LastGood != wantLastGood {
964- t.Fatalf("LastGood = %d, want %d", result.LastGood, wantLastGood)
965- }
966- })
967- }
968-}
969-
970-// A file written by the released version must keep working: it opens with the
971-// original semantics, is upgraded in place, and reopens identically.
972-func TestVEVKV1FileOpensAndUpgradesInPlace(t *testing.T) {
973- setA, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
974- setB, _ := encodeRecord(opSet, []byte("b"), bytes.Repeat([]byte("x"), 1<<20))
975- for _, tc := range []struct {
976- name string
977- wal []byte
978- }{
979- {name: "ordinary catalogue payload", wal: v1File(setA)},
980- {name: "prior large catalogue payload", wal: v1File(setB)},
981- } {
982- t.Run(tc.name, func(t *testing.T) {
983- path := privatePath(t)
984- writeFile(t, path, tc.wal)
985- store, err := Open(path)
986- if err != nil {
987- t.Fatal(err)
988- }
989- closeStore(t, store)
990- upgraded, err := os.ReadFile(path)
991- if err != nil {
992- t.Fatal(err)
993- }
994- if !bytes.Equal(upgraded[:fileHeaderLen], fileHeader()) {
995- t.Fatalf("upgraded header = %x, want %x", upgraded[:fileHeaderLen], fileHeader())
996- }
997- })
998- }
999-}
1000-
1001-func TestVEVKV1RejectsOversizedRecordBeforeAllocation(t *testing.T) {
1002- path := privatePath(t)
1003- writeFile(t, path, v1File(oversizedRecordHeader(maxPayloadLen+1)))
1004- if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
1005- t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
1006- }
1007-}
1008-
1009-func TestLegacyFileOpensAndUpgradesInPlace(t *testing.T) {
1010- setA, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
1011- setB, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
1012- replaceA, _ := encodeRecord(opSet, []byte("a"), []byte("uno"))
1013- delB, _ := encodeRecord(opDel, []byte("b"), nil)
1014- torn, _ := encodeRecord(opSet, []byte("c"), []byte("three"))
1015- tests := []struct {
1016- name string
1017- wal []byte
1018- want map[string]string
1019- }{
1020- {name: "complete", wal: legacyFile(setA, setB, replaceA), want: map[string]string{"a": "uno", "b": "two"}},
1021- {name: "tombstone", wal: legacyFile(setA, setB, delB), want: map[string]string{"a": "one"}},
1022- {name: "torn-tail", wal: append(legacyFile(setA, setB), legacyFile(torn)[:legacyHeaderLen+2]...), want: map[string]string{"a": "one", "b": "two"}},
1023- {name: "empty", wal: nil, want: map[string]string{}},
1024- }
1025- for _, tt := range tests {
1026- t.Run(tt.name, func(t *testing.T) {
1027- path := privatePath(t)
1028- writeFile(t, path, tt.wal)
1029-
1030- s, err := Open(path)
1031- if err != nil {
1032- t.Fatalf("legacy file failed to open: %v", err)
1033- }
1034- requireContents(t, s, tt.want)
1035- if err := s.Set([]byte("added"), []byte("after-upgrade")); err != nil {
1036- t.Fatal(err)
1037- }
1038- closeStore(t, s)
1039-
1040- upgraded, err := os.ReadFile(path)
1041- if err != nil {
1042- t.Fatal(err)
1043- }
1044- if !bytes.HasPrefix(upgraded, fileMagic[:]) {
1045- t.Fatal("legacy file was not upgraded in place")
1046- }
1047- reopened, err := Open(path)
1048- if err != nil {
1049- t.Fatal(err)
1050- }
1051- defer closeStore(t, reopened)
1052- want := map[string]string{"added": "after-upgrade"}
1053- maps.Copy(want, tt.want)
1054- requireContents(t, reopened, want)
1055- replayed, err := Replay(path)
1056- if err != nil {
1057- t.Fatal(err)
1058- }
1059- if len(replayed) != len(want) {
1060- t.Fatalf("Replay = %#v, want %#v", replayed, want)
1061- }
1062- })
1063- }
1064-}
1065-
1066-func requireContents(t *testing.T, s *Store, want map[string]string) {
1067- t.Helper()
1068- for key, value := range want {
1069- got, ok := s.Get([]byte(key))
1070- if !ok || string(got) != value {
1071- t.Fatalf("Get(%q) = %q, %v; want %q", key, got, ok, value)
1072- }
1073- }
1074- count := 0
1075- s.Range(func([]byte, []byte) bool { count++; return true })
1076- if count != len(want) {
1077- t.Fatalf("store holds %d keys, want %d", count, len(want))
1078- }
1079-}
1080-
1081-// The upgrade runs through the compaction protocol, so a crash at any of its
1082-// boundaries must leave either the whole legacy file or the whole upgraded one.
1083-func TestLegacyUpgradeCrashSafety(t *testing.T) {
1084- setA, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
1085- setB, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
1086- legacy := legacyFile(setA, setB)
1087- want := map[string]string{"a": "one", "b": "two"}
1088-
1089- for _, point := range compactionFaultPoints {
1090- t.Run("fault-"+point, func(t *testing.T) {
1091- path := privatePath(t)
1092- writeFile(t, path, legacy)
1093- hooks := defaultFSHooks()
1094- hooks.Fault = func(got string) error {
1095- if got == point {
1096- return errors.New("injected " + point)
1097- }
1098- return nil
1099- }
1100- if _, err := openWithHooks(path, hooks); err == nil {
1101- t.Fatalf("fault %q did not interrupt the upgrade", point)
1102- }
1103-
1104- // Restart: recovery selects a complete file, and the data is intact
1105- // whichever format that file happens to be in.
1106- s, err := Open(path)
1107- if err != nil {
1108- t.Fatalf("restart after fault %q: %v", point, err)
1109- }
1110- requireContents(t, s, want)
1111- closeStore(t, s)
1112- upgraded, err := os.ReadFile(path)
1113- if err != nil {
1114- t.Fatal(err)
1115- }
1116- if !bytes.HasPrefix(upgraded, fileMagic[:]) {
1117- t.Fatal("restart did not complete the upgrade")
1118- }
1119- })
1120- }
1121 }192 }

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 12

Tags 11

↑↓ moveenter openesc close