refactor(kv): replace the WAL with whole-file atomic rewrite
- commit
- 4776aab499af8e79d923d4a85d1170b5d660840a
- author
- brice · · 2026-07-26 12:29 UTC
- parent
- 97f1e81
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 46 | 46 | }{ | |
| 47 | 47 | {name: "normal"}, | |
| 48 | 48 | { | |
| 49 | - | name: ".prev crash hook", | |
| 49 | + | name: "stray tmp is ignored", | |
| 50 | 50 | crashHook: func(t *testing.T, stateDir string) { | |
| 51 | 51 | t.Helper() | |
| 52 | 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 | } | |
| @@ -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) { | |||
| 501 | 501 | ||
| 502 | 502 | func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) { | |
| 503 | 503 | log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode) | |
| 504 | - | log.Info("catalogue_compaction_recovery_complete", "recovery", recoveryMode) | |
| 505 | 504 | } | |
| 506 | 505 | ||
| 507 | 506 | 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) { | |||
| 88 | 88 | }, deps)) | |
| 89 | 89 | ||
| 90 | 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 | 92 | require.Contains(t, eventNames(entries), name) | |
| 93 | 93 | } | |
| 94 | 94 | 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) { | |||
| 19 | 19 | t.Run("metadata-update-preserves-authority", testCatalogueMetadataUpdatePreservesAuthority) | |
| 20 | 20 | t.Run("read-only-malformed", testCatalogueLoadReadOnlyRejectsMalformedValue) | |
| 21 | 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 | 25 | func testCatalogueRecordRoundTrip(t *testing.T) { | |
| @@ -87,14 +87,13 @@ func testCatalogueOpenFailsClosed(t *testing.T) { | |||
| 87 | 87 | require.Error(t, err) | |
| 88 | 88 | ||
| 89 | 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 | 92 | _, _, 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) | |
| 98 | 97 | } | |
| 99 | 98 | ||
| 100 | 99 | func testCatalogueApplyRenameReplace(t *testing.T) { | |
| @@ -161,11 +160,6 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) { | |||
| 161 | 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 | 163 | func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) { | |
| 170 | 164 | dir := privateDir(t) | |
| 171 | 165 | store, err := kv.Open(filepath.Join(dir, filename)) | |
| @@ -177,9 +171,8 @@ func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) { | |||
| 177 | 171 | } | |
| 178 | 172 | ||
| 179 | 173 | // 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. | |
| 183 | 176 | func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) { | |
| 184 | 177 | dir := privateDir(t) | |
| 185 | 178 | records, err := LoadCatalogueReadOnly(dir) | |
| @@ -187,80 +180,19 @@ func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) { | |||
| 187 | 180 | require.Empty(t, records) | |
| 188 | 181 | } | |
| 189 | 182 | ||
| 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)) | |
| 257 | 191 | ||
| 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) | |
| 266 | 198 | } | |
| @@ -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 a | 173 | // testCatalogueLoadReadOnlyFreshInstallHasNoSessions covers `vev ls` on a | ||
| 180 | - | // machine with no daemon and no catalogue: none of sessions.kv, .next, or | 174 | + | // 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 acceptance | 183 | + | func testCatalogueLoadReadOnlyIgnoresStrayTmp(t *testing.T) { |
| 191 | - | // criterion 3 ("every valid catalogue record appears in vev list") for a | 184 | + | dir := privateDir(t) |
| 192 | - | // listing taken while no daemon is running: whenever any compaction | 185 | + | p, _, err := openCurrentCatalogue(dir, true) |
| 193 | - | // candidate exists, LoadCatalogueReadOnly must apply the same fixed-path | 186 | + | 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, rather | 189 | + | require.NoError(t, p.Close()) |
| 197 | - | // than kv.Open'd directly at the target path: kv.Open recovers its own | 190 | + | 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 @@ | |||
| 1 | 1 | package persist | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/binary" | |
| 5 | 4 | "errors" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "os" | |
| @@ -14,10 +13,7 @@ import ( | |||
| 14 | 13 | "github.com/bnema/vev/pkg/kv" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | - | const ( | |
| 18 | - | filename = "sessions.kv" | |
| 19 | - | currentCatalogueVersion = 2 | |
| 20 | - | ) | |
| 16 | + | const filename = "sessions.kv" | |
| 21 | 17 | ||
| 22 | 18 | var errPersistenceUnavailable = errors.New("persist: catalogue unavailable") | |
| 23 | 19 | ||
| @@ -43,7 +39,7 @@ type Persister struct { | |||
| 43 | 39 | 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 | 43 | type KVStore struct{ store *kv.Store } | |
| 48 | 44 | ||
| 49 | 45 | func OpenStore(path string) (*KVStore, error) { | |
| @@ -62,13 +58,6 @@ func (s *KVStore) Range(fn func(key, value []byte) bool) { | |||
| 62 | 58 | } | |
| 63 | 59 | func (s *KVStore) Sync() error { return s.store.Sync() } | |
| 64 | 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 | 62 | // Open opens an existing strict catalogue and never creates an unproven empty one. | |
| 74 | 63 | func Open(dir string) (*Persister, error) { | |
| @@ -86,31 +75,6 @@ func OpenOrCreate(dir string) (OpenResult, error) { | |||
| 86 | 75 | if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { | |
| 87 | 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 | 78 | catalogue, records, err := openCurrentCatalogue(dir, true) | |
| 115 | 79 | if err != nil { | |
| 116 | 80 | return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err) | |
| @@ -120,20 +84,18 @@ func OpenOrCreate(dir string) (OpenResult, error) { | |||
| 120 | 84 | ||
| 121 | 85 | func New(store ports.Store) *Persister { return &Persister{store: store} } | |
| 122 | 86 | ||
| 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 | |
| 130 | 92 | } | |
| 131 | 93 | return false, nil | |
| 132 | 94 | } | |
| 133 | 95 | ||
| 134 | 96 | func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) { | |
| 135 | 97 | path := StorePath(dir) | |
| 136 | - | present, err := catalogueCandidatesPresent(path) | |
| 98 | + | present, err := cataloguePresent(path) | |
| 137 | 99 | if err != nil { | |
| 138 | 100 | return nil, nil, err | |
| 139 | 101 | } | |
| @@ -155,12 +117,8 @@ func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []dom | |||
| 155 | 117 | func LoadReadOnly(dir string) ([]domain.CatalogueRecord, error) { return LoadCatalogueReadOnly(dir) } | |
| 156 | 118 | ||
| 157 | 119 | // 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. | |
| 164 | 122 | func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) { | |
| 165 | 123 | p, records, err := openCurrentCatalogue(dir, false) | |
| 166 | 124 | if err != nil { | |
| @@ -223,7 +181,12 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro | |||
| 223 | 181 | if err := p.ensureIncarnationIndexLocked(); err != nil { | |
| 224 | 182 | 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 | 190 | keys := make([]string, 0, len(records)) | |
| 228 | 191 | for name := range records { | |
| 229 | 192 | keys = append(keys, name) | |
| @@ -235,7 +198,7 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro | |||
| 235 | 198 | return err | |
| 236 | 199 | } | |
| 237 | 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 | 202 | continue | |
| 240 | 203 | } | |
| 241 | 204 | if record.Name != name { | |
| @@ -245,7 +208,7 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro | |||
| 245 | 208 | if err != nil { | |
| 246 | 209 | 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 | 213 | incoming := make(map[domain.IncarnationID]string, len(records)) | |
| 251 | 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 { | |
| 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 | + | } | |
| 269 | 240 | } | |
| 270 | 241 | if err := p.store.Sync(); err != nil { | |
| 271 | 242 | p.incarnationIndex = false | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package persist | 1 | 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 bool | 39 | 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, nil | 90 | + | } 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, nil | 93 | 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, err | 100 | 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: it | 120 | + | // A fresh install with no main catalogue is not an error. Stray temporary |
| 159 | - | // returns an empty slice. Whenever any candidate (sessions.kv, .next, or | 121 | + | // 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 err | 182 | 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 err | 198 | 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 | continue | 202 | 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 err | 209 | 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 = false | 230 | + | var err error |
| 268 | - | return err | 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 | + | } | |||
| 269 | } | 240 | } | ||
| 270 | if err := p.store.Sync(); err != nil { | 241 | if err := p.store.Sync(); err != nil { | ||
| 271 | p.incarnationIndex = false | 242 | p.incarnationIndex = false | ||
M internal/persist/persist_test.go +16 -29
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| @@ -67,18 +66,6 @@ func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T) | |||
| 67 | 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 | 70 | for _, tt := range tests { | |
| 84 | 71 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -92,7 +79,10 @@ func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T) | |||
| 92 | 79 | before := directoryBytes(t, dir) | |
| 93 | 80 | _, err := OpenOrCreate(dir) | |
| 94 | 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 | 108 | require.Error(t, err) | |
| 119 | 109 | } | |
| 120 | 110 | ||
| 121 | - | func TestCatalogueBatchSyncBehavior(t *testing.T) { | |
| 111 | + | func TestCatalogueMutationsUseOneSync(t *testing.T) { | |
| 122 | 112 | store := portsmocks.NewMockStore(t) | |
| 123 | 113 | state := map[string][]byte{} | |
| 124 | 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 | 120 | }).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)) | |
| 139 | 127 | return nil | |
| 140 | - | }).Twice() | |
| 128 | + | }).Once() | |
| 141 | 129 | store.EXPECT().Sync().Return(nil).Twice() | |
| 142 | 130 | p := New(store) | |
| 143 | 131 | one := validRecord("one", 1) | |
| 144 | 132 | require.NoError(t, p.Replace("one", one)) | |
| 145 | - | require.Contains(t, state, "one") | |
| 146 | 133 | renamed := one | |
| 147 | 134 | renamed.Name = "renamed" | |
| 148 | 135 | renamed.Cwd = "/renamed" | |
| @@ -155,14 +142,14 @@ func TestCatalogueBatchSyncBehavior(t *testing.T) { | |||
| 155 | 142 | require.Equal(t, renamed, got) | |
| 156 | 143 | } | |
| 157 | 144 | ||
| 158 | - | func TestCatalogueBatchFailureDoesNotSync(t *testing.T) { | |
| 145 | + | func TestCatalogueMutationFailureDoesNotSync(t *testing.T) { | |
| 159 | 146 | store := portsmocks.NewMockStore(t) | |
| 160 | - | batchErr := errors.New("batch failed") | |
| 147 | + | setErr := errors.New("set failed") | |
| 161 | 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 | 150 | p := New(store) | |
| 164 | 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 | 155 | // 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 nil | 127 | 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 := one | 133 | 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-persistence | 155 | // TestCatalogueNilPersisterIsNoOp covers the ephemeral/optional-persistence | ||
M internal/ports/mocks/store_mock.go +0 -52
| @@ -5,7 +5,6 @@ | |||
| 5 | 5 | package portsmocks | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | - | "github.com/bnema/vev/internal/ports" | |
| 9 | 8 | mock "github.com/stretchr/testify/mock" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| @@ -36,57 +35,6 @@ func (_m *MockStore) EXPECT() *MockStore_Expecter { | |||
| 36 | 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 | 38 | // Close provides a mock function for the type MockStore | |
| 91 | 39 | func (_mock *MockStore) Close() error { | |
| 92 | 40 | ret := _mock.Called() | |
| @@ -5,7 +5,6 @@ | |||||
| 5 | package portsmocks | 5 | 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 MockStore | 38 | // 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 | |||
| 2 | 2 | ||
| 3 | 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 | 5 | // Store is a small byte-key/value persistence port. | |
| 13 | 6 | // | |
| 14 | 7 | // Implementations may buffer writes; Sync is the durability barrier. | |
| @@ -18,12 +11,6 @@ type Store interface { | |||
| 18 | 11 | Get(key []byte) ([]byte, bool) | |
| 19 | 12 | Set(key, val []byte) error | |
| 20 | 13 | 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 | 14 | // Range iterates key/value pairs; fn returning false stops iteration early. | |
| 28 | 15 | // Each key and value is a stable copy owned by the caller, which may retain | |
| 29 | 16 | // 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) error | 12 | Set(key, val []byte) error | ||
| 20 | Delete(key []byte) error | 13 | 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 retain | 15 | // 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) { | |||
| 701 | 701 | state.data[string(k)] = append([]byte(nil), v...) | |
| 702 | 702 | return nil | |
| 703 | 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 | 704 | store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error { | |
| 748 | 705 | state.mu.Lock() | |
| 749 | 706 | key := string(k) | |
| @@ -815,32 +772,12 @@ func (s newStaticStore) Get(key []byte) ([]byte, bool) { | |||
| 815 | 772 | v, ok := s[string(key)] | |
| 816 | 773 | return append([]byte(nil), v...), ok | |
| 817 | 774 | } | |
| 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)) | |
| 844 | 781 | return nil | |
| 845 | 782 | } | |
| 846 | 783 | func (s newStaticStore) Range(fn func(k, v []byte) bool) { | |
| @@ -1251,11 +1188,8 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T) | |||
| 1251 | 1188 | var attempted map[string][]byte | |
| 1252 | 1189 | store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Times(3) | |
| 1253 | 1190 | 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...)} | |
| 1259 | 1193 | return errors.New("disk full") | |
| 1260 | 1194 | }).Once() | |
| 1261 | 1195 | clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}} | |
| @@ -1274,7 +1208,7 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T) | |||
| 1274 | 1208 | require.Len(t, records, 1) | |
| 1275 | 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 | 1212 | store.EXPECT().Sync().Return(nil).Once() | |
| 1279 | 1213 | require.NoError(t, d.renameSession(sess, "named")) | |
| 1280 | 1214 | 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 nil | 702 | 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...), ok | 773 | 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 nil | 781 | 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][]byte | 1188 | 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "hash/crc32" | |
| 7 | 7 | "math" | |
| 8 | + | "sort" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 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") | |
| 51 | 12 | ||
| 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'} | |
| 55 | 14 | ||
| 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 | |
| 59 | 20 | ) | |
| 60 | 21 | ||
| 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) | |
| 155 | 46 | } | |
| 156 | 47 | ||
| 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)) | |
| 164 | 50 | } | |
| 165 | 51 | ||
| 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 | |
| 173 | 57 | } | |
| 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 | |
| 177 | 62 | } | |
| 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 | |
| 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) { | |
| 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) | |
| 204 | 70 | 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 | |
| 207 | 74 | } | |
| 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 | |
| 212 | 78 | } | |
| 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 | |
| 217 | 84 | } | |
| 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 | |
| 222 | 86 | if valueLen > uint64(len(payload)-off) { | |
| 223 | - | return nil, errBadRecord | |
| 87 | + | return nil, ErrCorrupt | |
| 224 | 88 | } | |
| 225 | - | if op == opDel && valueLen != 0 { | |
| 226 | - | return nil, errBadRecord | |
| 89 | + | if _, duplicate := data[key]; duplicate { | |
| 90 | + | 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 | 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 | 95 | 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 | |
| 238 | 108 | } | |
| 239 | - | return records, nil | |
| 109 | + | return length, off + 4, true | |
| 240 | 110 | } | |
| @@ -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 legacy | 13 | + | 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 byte | 23 | + | func encodeFile(data map[string][]byte) []byte { |
| 63 | - | key []byte | 24 | + | keys := make([]string, 0, len(data)) |
| 64 | - | value []byte | 25 | + | 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 []byte | 30 | + | size := filePrefixLen + crcLen |
| 70 | - | Value []byte | 31 | + | for _, key := range keys { |
| 71 | - | Delete bool | 32 | + | 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, errBadRecord | 40 | + | 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{}, errBadRecord | 54 | + | 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{}, errBadRecord | 60 | + | 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{}, errBadRecord | 64 | + | 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, errBadRecord | 69 | + | 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, errBadRecord | 72 | + | 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 += 2 | 80 | + | off += int(keyLen) |
| 215 | - | if keyLen > len(payload)-off-4 { | 81 | + | valueLen, next, ok := decodeLength(payload, off) |
| 216 | - | return nil, errBadRecord | 82 | + | 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, errBadRecord | 87 | + | return nil, ErrCorrupt |
| 224 | } | 88 | } | ||
| 225 | - | if op == opDel && valueLen != 0 { | 89 | + | if _, duplicate := data[key]; duplicate { |
| 226 | - | return nil, errBadRecord | 90 | + | 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, 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 | |||
| 238 | } | 108 | } | ||
| 239 | - | return records, nil | 109 | + | return length, off + 4, true |
| 240 | } | 110 | } | ||
M pkg/kv/kv.go +96 -665
| @@ -1,11 +1,7 @@ | |||
| 1 | 1 | package kv | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "encoding/binary" | |
| 6 | 4 | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "hash/crc32" | |
| 9 | 5 | "io" | |
| 10 | 6 | "os" | |
| 11 | 7 | "path/filepath" | |
| @@ -16,99 +12,20 @@ import ( | |||
| 16 | 12 | "github.com/bnema/vev/pkg/safedir" | |
| 17 | 13 | ) | |
| 18 | 14 | ||
| 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. | |
| 95 | 17 | 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 | |
| 106 | 24 | } | |
| 107 | 25 | ||
| 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) { | |
| 112 | 29 | if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil { | |
| 113 | 30 | return nil, err | |
| 114 | 31 | } | |
| @@ -116,654 +33,168 @@ func openWithHooks(path string, hooks fsHooks) (*Store, error) { | |||
| 116 | 33 | if err != nil { | |
| 117 | 34 | return nil, err | |
| 118 | 35 | } | |
| 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)) | |
| 155 | 38 | } | |
| 156 | - | return s, nil | |
| 157 | - | } | |
| 158 | 39 | ||
| 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) | |
| 162 | 41 | 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 | |
| 164 | 47 | } | |
| 165 | 48 | if err != nil { | |
| 166 | - | return nil, err | |
| 49 | + | return fail(err) | |
| 167 | 50 | } | |
| 168 | - | defer func() { _ = f.Close() }() | |
| 169 | - | result, err := replayFile(f) | |
| 51 | + | data, err := decodeFile(raw) | |
| 170 | 52 | if err != nil { | |
| 171 | - | return nil, err | |
| 53 | + | return fail(err) | |
| 172 | 54 | } | |
| 173 | - | return result.Data, nil | |
| 55 | + | return &Store{path: path, lockFile: lockFile, data: data}, nil | |
| 174 | 56 | } | |
| 175 | 57 | ||
| 176 | 58 | func (s *Store) Get(key []byte) ([]byte, bool) { | |
| 177 | 59 | s.mu.Lock() | |
| 178 | 60 | 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 | |
| 193 | 63 | } | |
| 194 | 64 | ||
| 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 { | |
| 232 | 66 | s.mu.Lock() | |
| 233 | 67 | defer s.mu.Unlock() | |
| 234 | 68 | if s.closed { | |
| 235 | 69 | 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 | 77 | s.mu.Lock() | |
| 241 | 78 | defer s.mu.Unlock() | |
| 242 | 79 | if s.closed { | |
| 243 | - | return nil | |
| 80 | + | return os.ErrClosed | |
| 244 | 81 | } | |
| 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 | |
| 248 | 87 | } | |
| 249 | 88 | ||
| 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) | |
| 254 | 94 | } | |
| 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]...) | |
| 258 | 99 | } | |
| 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 | + | } | |
| 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 { | |
| 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 { | |
| 273 | 112 | s.mu.Lock() | |
| 274 | 113 | defer s.mu.Unlock() | |
| 275 | 114 | if s.closed { | |
| 276 | 115 | return os.ErrClosed | |
| 277 | 116 | } | |
| 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() | |
| 303 | 118 | } | |
| 304 | 119 | ||
| 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 | |
| 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 | 127 | if err != nil { | |
| 412 | 128 | return err | |
| 413 | 129 | } | |
| 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()) | |
| 429 | 132 | } | |
| 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()) | |
| 435 | 135 | } | |
| 436 | - | if err := removeIfExists(s.hooks, prev); err != nil { | |
| 136 | + | if err := file.Close(); err != nil { | |
| 437 | 137 | 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 | 140 | return err | |
| 441 | 141 | } | |
| 442 | - | if err := s.hooks.SyncDir(dir); err != nil { | |
| 142 | + | if err := syncDir(filepath.Dir(s.path)); err != nil { | |
| 443 | 143 | 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. | |
| 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() | |
| 452 | 153 | if s.closed { | |
| 453 | 154 | return nil | |
| 454 | 155 | } | |
| 156 | + | err := s.syncLocked() | |
| 455 | 157 | 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)) | |
| 463 | 159 | } | |
| 464 | 160 | ||
| 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) | |
| 513 | 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 | 165 | return err | |
| 709 | 166 | } | |
| 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 | |
| 719 | 169 | } | |
| 720 | - | return hooks.SyncDir(dir) | |
| 170 | + | data = data[n:] | |
| 721 | 171 | } | |
| 722 | 172 | return nil | |
| 723 | 173 | } | |
| 724 | 174 | ||
| 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) | |
| 739 | 177 | 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 | |
| 746 | 179 | } | |
| 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 | |
| 753 | 183 | } | |
| 754 | - | return err | |
| 184 | + | return file, nil | |
| 755 | 185 | } | |
| 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 { | |
| 758 | 189 | 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 | 194 | func syncDir(dir string) error { | |
| 764 | - | f, err := os.Open(dir) | |
| 195 | + | file, err := os.Open(dir) | |
| 765 | 196 | if err != nil { | |
| 766 | 197 | return err | |
| 767 | 198 | } | |
| 768 | - | return errors.Join(f.Sync(), f.Close()) | |
| 199 | + | return errors.Join(file.Sync(), file.Close()) | |
| 769 | 200 | } | |
| @@ -1,11 +1,7 @@ | |||||
| 1 | package kv | 1 | 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 an | 15 | + | // 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 file | 16 | + | // 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.Mutex | 18 | + | mu sync.Mutex |
| 97 | - | path string | 19 | + | path string |
| 98 | - | file *os.File | 20 | + | lockFile *os.File |
| 99 | - | lockFile *os.File | 21 | + | data map[string][]byte |
| 100 | - | data map[string][]byte | 22 | + | dirty bool |
| 101 | - | entrySize map[string]int64 | 23 | + | 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, err | 30 | 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, err | 34 | 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{}, 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 | |||
| 164 | } | 47 | } | ||
| 165 | if err != nil { | 48 | if err != nil { | ||
| 166 | - | return nil, err | 49 | + | 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, err | 53 | + | return fail(err) |
| 172 | } | 54 | } | ||
| 173 | - | return result.Data, nil | 55 | + | 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...), ok | 62 | + | 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.ErrClosed | 69 | 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 nil | 80 | + | 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 = true | 83 | + | delete(s.data, string(key)) |
| 247 | - | return err | 84 | + | 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, err | 92 | + | 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, err | 97 | + | for i, key := range keys { |
| 98 | + | values[i] = append([]byte(nil), s.data[key]...) | |||
| 258 | } | 99 | } | ||
| 259 | - | return f, nil | 100 | + | 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 nil | 104 | + | } |
| 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 err | 111 | + | 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.ErrClosed | 115 | 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 err | 128 | 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.file | 133 | + | if err := file.Sync(); err != nil { |
| 431 | - | s.file = current | 134 | + | 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 err | 137 | 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 err | 140 | 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 err | 143 | 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 a | 150 | + | 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 nil | 154 | return nil | ||
| 454 | } | 155 | } | ||
| 156 | + | err := s.syncLocked() | |||
| 455 | s.closed = true | 157 | s.closed = true | ||
| 456 | - | file, lockFile := s.file, s.lockFile | 158 | + | 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 torn | 161 | + | func writeAll(file *os.File, data []byte) error { |
| 466 | - | // tail is the only truncatable outcome; every other inconsistency is corruption | 162 | + | for len(data) > 0 { |
| 467 | - | // and fails closed, so a damaged record can never be silently dropped along | 163 | + | 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 err | 165 | return err | ||
| 709 | } | 166 | } | ||
| 710 | - | if err := hooks.SyncDir(dir); err != nil { | 167 | + | if n == 0 { |
| 711 | - | return err | 168 | + | 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 nil | 172 | 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 err | 178 | + | 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 nil | 180 | + | 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 err | 184 | + | 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 nil | 189 | 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 err | 197 | 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 @@ | |||
| 1 | 1 | package kv | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "encoding/binary" | |
| 6 | 5 | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "hash/crc32" | |
| 9 | - | "maps" | |
| 10 | 6 | "os" | |
| 11 | 7 | "path/filepath" | |
| 12 | - | "reflect" | |
| 13 | 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{ | |
| 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() | |
| 577 | 15 | tests := []struct { | |
| 578 | 16 | name string | |
| 579 | - | configure func(fsHooks, string) fsHooks | |
| 17 | + | leaveTmp bool | |
| 18 | + | corrupt bool | |
| 19 | + | wantOpen bool | |
| 20 | + | wantValue string | |
| 580 | 21 | }{ | |
| 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}, | |
| 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 | 26 | for _, tt := range tests { | |
| 636 | 27 | 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()) | |
| 650 | 35 | ||
| 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)) | |
| 654 | 38 | } | |
| 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)) | |
| 658 | 44 | } | |
| 659 | - | requireCandidateBytes(t, path, beforeAppend) | |
| 660 | 45 | ||
| 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 | |
| 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 { | |
| 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()) | |
| 720 | 71 | ||
| 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) | |
| 760 | 79 | } | |
| 761 | 80 | ||
| 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() | |
| 769 | 83 | tests := []struct { | |
| 770 | - | name string | |
| 771 | - | current []byte | |
| 772 | - | standalone bool | |
| 84 | + | name string | |
| 85 | + | raw []byte | |
| 773 | 86 | }{ | |
| 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)}, | |
| 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 | 93 | for _, tt := range tests { | |
| 795 | 94 | 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) | |
| 824 | 103 | }) | |
| 825 | 104 | } | |
| 826 | 105 | } | |
| 827 | 106 | ||
| 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 | |
| 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 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) | |
| 861 | 159 | } | |
| 862 | 160 | ||
| 863 | - | func requireFileBytes(t *testing.T, path string, want []byte) { | |
| 161 | + | func privateStorePath(t *testing.T) string { | |
| 864 | 162 | 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)) | |
| 1121 | 192 | } | |
| @@ -1,1121 +1,192 @@ | |||||
| 1 | package kv | 1 | 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 string | 16 | name string | ||
| 579 | - | configure func(fsHooks, string) fsHooks | 17 | + | 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 h | 24 | + | {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.Remove | 62 | + | path := privateStorePath(t) |
| 679 | - | calls := 0 | 63 | + | 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 fault | 67 | + | 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 | - | continue | 78 | + | 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 is | 81 | + | func TestOpenFailsClosedWithoutMutation(t *testing.T) { |
| 763 | - | // accepted only after complete strict replay, while the current header checksum | 82 | + | 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 string | 84 | + | name string |
| 771 | - | current []byte | 85 | + | 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 | - | continue | 97 | + | 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 the | 147 | + | 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 header | 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) | |||
| 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 an | 172 | + | func TestDecodeRejectsDuplicateKeysAndTrailingData(t *testing.T) { |
| 875 | - | // early record used to look exactly like a torn tail, so Open truncated every | 173 | + | 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 string | 181 | + | payload = binary.BigEndian.AppendUint32(payload, 1) |
| 884 | - | offset int | 182 | + | 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 | } | ||