feat(recover): add directory recovery over fsys
- commit
- b62a1d44e120122213c217f710dac9cf6b7e73f6
- author
- brice · · 2026-10-10 01:52 UTC
- committer
- brice · · 2026-10-10 01:57 UTC
- parent
- 15cd1c5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +895 -0
A recover.go +330 -0
| @@ -0,0 +1,330 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package kv | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "log/slog" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "slices" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite-kv/internal/fsys" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | lockName = "LOCK" | |
| 21 | + | snapshotPrefix = "snapshot-" | |
| 22 | + | logPrefix = "log-" | |
| 23 | + | tmpSuffix = ".tmp" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | func snapshotName(seq uint64) string { return fmt.Sprintf("%s%016x", snapshotPrefix, seq) } | |
| 27 | + | func logName(base uint64) string { return fmt.Sprintf("%s%016x", logPrefix, base) } | |
| 28 | + | ||
| 29 | + | // parseSeqName parses "<prefix><seq:016x>"; ok is false for any other name. | |
| 30 | + | func parseSeqName(name, prefix string) (seq uint64, ok bool) { | |
| 31 | + | if !strings.HasPrefix(name, prefix) { | |
| 32 | + | return 0, false | |
| 33 | + | } | |
| 34 | + | seq, err := strconv.ParseUint(name[len(prefix):], 16, 64) | |
| 35 | + | if err != nil || fmt.Sprintf("%s%016x", prefix, seq) != name { | |
| 36 | + | return 0, false | |
| 37 | + | } | |
| 38 | + | return seq, true | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // recovered is the state handed to the DB by recoverDir. | |
| 42 | + | type recovered struct { | |
| 43 | + | snapSeq uint64 // seq of the loaded snapshot, 0 if none | |
| 44 | + | lastSeq uint64 // seq of the last applied record (snapSeq if none) | |
| 45 | + | ||
| 46 | + | // log is the last log file, opened read-write with appending writes. It | |
| 47 | + | // is at logBase and holds logSize bytes (header included) after recovery. | |
| 48 | + | // The caller owns it. | |
| 49 | + | log fsys.File | |
| 50 | + | logBase uint64 | |
| 51 | + | logSize int64 | |
| 52 | + | ||
| 53 | + | // lock holds the directory lock; closing it releases the lock. The | |
| 54 | + | // caller owns it. | |
| 55 | + | lock io.Closer | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // recoverDir opens the database directory and rebuilds its state: recovery | |
| 59 | + | // rule 1 (directory, lock, *.tmp files) and rules 2–7. It does not depend on | |
| 60 | + | // the in-memory tree. | |
| 61 | + | // | |
| 62 | + | // load is called once per snapshot entry in ascending key order, only after | |
| 63 | + | // the whole snapshot file has been validated. apply is called for each log | |
| 64 | + | // record newer than the snapshot, in seq order. Both receive freshly | |
| 65 | + | // allocated slices that the callee may retain but must not modify; recovery | |
| 66 | + | // never reuses them (snapshot entries share one read-only buffer, log ops are | |
| 67 | + | // copied per record). Either callback may be nil. A nil logger discards logs. | |
| 68 | + | // | |
| 69 | + | // On error nothing stays open or locked. ErrLocked and ErrCorrupt are | |
| 70 | + | // returned wrapped. | |
| 71 | + | func recoverDir(fx fsys.FS, dir string, logger *slog.Logger, | |
| 72 | + | load func(key, value []byte, version uint64), | |
| 73 | + | apply func(seq uint64, ops []op), | |
| 74 | + | ) (rec recovered, err error) { | |
| 75 | + | if logger == nil { | |
| 76 | + | logger = slog.New(slog.DiscardHandler) | |
| 77 | + | } | |
| 78 | + | dir = filepath.Clean(dir) | |
| 79 | + | if err := fx.MkdirAll(dir, 0o700); err != nil { | |
| 80 | + | return rec, err | |
| 81 | + | } | |
| 82 | + | if err := fx.SyncDir(dir); err != nil { | |
| 83 | + | return rec, err | |
| 84 | + | } | |
| 85 | + | if parent := filepath.Dir(dir); parent != dir { | |
| 86 | + | if err := fx.SyncDir(parent); err != nil { | |
| 87 | + | return rec, err | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | lock, err := fx.Lock(filepath.Join(dir, lockName)) | |
| 91 | + | if errors.Is(err, fsys.ErrLocked) { | |
| 92 | + | return rec, fmt.Errorf("%w: %s", ErrLocked, dir) | |
| 93 | + | } | |
| 94 | + | if err != nil { | |
| 95 | + | return rec, err | |
| 96 | + | } | |
| 97 | + | rec.lock = lock | |
| 98 | + | defer func() { | |
| 99 | + | if err != nil { | |
| 100 | + | if rec.log != nil { | |
| 101 | + | _ = rec.log.Close() | |
| 102 | + | } | |
| 103 | + | _ = lock.Close() | |
| 104 | + | rec = recovered{} | |
| 105 | + | } | |
| 106 | + | }() | |
| 107 | + | ||
| 108 | + | names, err := fx.ReadDir(dir) | |
| 109 | + | if err != nil { | |
| 110 | + | return rec, err | |
| 111 | + | } | |
| 112 | + | var snaps, logs []uint64 | |
| 113 | + | for _, name := range names { | |
| 114 | + | if strings.HasSuffix(name, tmpSuffix) { | |
| 115 | + | if err := fx.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 116 | + | return rec, err | |
| 117 | + | } | |
| 118 | + | } else if seq, ok := parseSeqName(name, snapshotPrefix); ok { | |
| 119 | + | snaps = append(snaps, seq) | |
| 120 | + | } else if base, ok := parseSeqName(name, logPrefix); ok { | |
| 121 | + | logs = append(logs, base) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if err := fx.SyncDir(dir); err != nil { | |
| 125 | + | return rec, err | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Rule 2: load the highest-seq snapshot that validates. | |
| 129 | + | slices.Sort(snaps) | |
| 130 | + | for i := len(snaps) - 1; i >= 0; i-- { | |
| 131 | + | path := filepath.Join(dir, snapshotName(snaps[i])) | |
| 132 | + | data, err := readFile(fx, path) | |
| 133 | + | if err != nil { | |
| 134 | + | return rec, err | |
| 135 | + | } | |
| 136 | + | view, err := openSnapshot(data) | |
| 137 | + | if err == nil && view.seq != snaps[i] { | |
| 138 | + | err = fmt.Errorf("%w: snapshot seq %d does not match its name", ErrCorrupt, view.seq) | |
| 139 | + | } | |
| 140 | + | if err != nil { | |
| 141 | + | logger.Warn("kv: ignoring invalid snapshot", "file", path, "err", err) | |
| 142 | + | continue | |
| 143 | + | } | |
| 144 | + | if load != nil { | |
| 145 | + | view.each(load) | |
| 146 | + | } | |
| 147 | + | rec.snapSeq = view.seq | |
| 148 | + | break | |
| 149 | + | } | |
| 150 | + | rec.lastSeq = rec.snapSeq | |
| 151 | + | ||
| 152 | + | // Rules 3–6: replay the logs in base order. | |
| 153 | + | slices.Sort(logs) | |
| 154 | + | for i, base := range logs { | |
| 155 | + | last := i == len(logs)-1 | |
| 156 | + | path := filepath.Join(dir, logName(base)) | |
| 157 | + | if !last { | |
| 158 | + | data, err := readFile(fx, path) | |
| 159 | + | if err != nil { | |
| 160 | + | return rec, err | |
| 161 | + | } | |
| 162 | + | if _, err := replayLog(nil, data, path, base, &rec, logger, apply); err != nil { | |
| 163 | + | return rec, err | |
| 164 | + | } | |
| 165 | + | continue | |
| 166 | + | } | |
| 167 | + | f, err := fx.OpenFile(path, os.O_RDWR, 0) | |
| 168 | + | if err != nil { | |
| 169 | + | return rec, err | |
| 170 | + | } | |
| 171 | + | data, err := readAll(f) | |
| 172 | + | if err == nil { | |
| 173 | + | rec.logSize, err = replayLog(f, data, path, base, &rec, logger, apply) | |
| 174 | + | } | |
| 175 | + | if err != nil { | |
| 176 | + | _ = f.Close() | |
| 177 | + | return rec, err | |
| 178 | + | } | |
| 179 | + | rec.log, rec.logBase = f, base | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Rule 7: no log file at all. | |
| 183 | + | if rec.log == nil { | |
| 184 | + | name := logName(rec.snapSeq) | |
| 185 | + | header := appendLogHeader(nil, rec.snapSeq) | |
| 186 | + | f, err := createFile(fx, dir, name, func(w io.Writer) error { | |
| 187 | + | _, err := w.Write(header) | |
| 188 | + | return err | |
| 189 | + | }) | |
| 190 | + | if err != nil { | |
| 191 | + | return rec, err | |
| 192 | + | } | |
| 193 | + | rec.log, rec.logBase, rec.logSize = f, rec.snapSeq, logHeaderSize | |
| 194 | + | } | |
| 195 | + | return rec, nil | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // replayLog applies the records of the log file contents data (rules 3–6) | |
| 199 | + | // and updates rec.lastSeq. f is nil for a log that is not the last one; for | |
| 200 | + | // the last log it is used to truncate a torn tail. It returns the size of the | |
| 201 | + | // log after recovery. | |
| 202 | + | func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovered, | |
| 203 | + | logger *slog.Logger, apply func(seq uint64, ops []op), | |
| 204 | + | ) (int64, error) { | |
| 205 | + | last := f != nil | |
| 206 | + | corrupt := func(off int, format string, args ...any) error { | |
| 207 | + | return fmt.Errorf("%w: %s at offset %d: %s", ErrCorrupt, path, off, fmt.Sprintf(format, args...)) | |
| 208 | + | } | |
| 209 | + | hdrBase, err := decodeLogHeader(data) | |
| 210 | + | if err != nil { | |
| 211 | + | return 0, fmt.Errorf("%s: %w", path, err) | |
| 212 | + | } | |
| 213 | + | if hdrBase != base { | |
| 214 | + | return 0, corrupt(0, "header base %d does not match the file name", hdrBase) | |
| 215 | + | } | |
| 216 | + | if base > rec.lastSeq { | |
| 217 | + | return 0, corrupt(0, "base %d is above the last recovered seq %d", base, rec.lastSeq) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | var ops []op | |
| 221 | + | expected := base + 1 // seq of the next record of this file | |
| 222 | + | off := logHeaderSize | |
| 223 | + | for off < len(data) { | |
| 224 | + | seq, decoded, n, err := decodeRecord(data[off:], ops) | |
| 225 | + | if err != nil { | |
| 226 | + | if !last { | |
| 227 | + | return 0, corrupt(off, "%v", err) | |
| 228 | + | } | |
| 229 | + | // Rule 6: a readable next record means acknowledged data | |
| 230 | + | // lies beyond the damage. | |
| 231 | + | if total, ok := peekRecord(data[off:]); ok && off+total < len(data) { | |
| 232 | + | nseq, _, _, nerr := decodeRecord(data[off+total:], nil) | |
| 233 | + | if nerr == nil && nseq == expected+1 { | |
| 234 | + | return 0, corrupt(off, "%v, followed by a valid record", err) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | if err := f.Truncate(int64(off)); err != nil { | |
| 238 | + | return 0, err | |
| 239 | + | } | |
| 240 | + | if err := f.SyncAll(); err != nil { | |
| 241 | + | return 0, err | |
| 242 | + | } | |
| 243 | + | logger.Warn("kv: dropped torn log tail", "file", path, "offset", off, "bytes", len(data)-off) | |
| 244 | + | data = data[:off] | |
| 245 | + | break | |
| 246 | + | } | |
| 247 | + | ops = decoded[:0] | |
| 248 | + | if seq != expected { | |
| 249 | + | return 0, corrupt(off, "record seq %d, want %d", seq, expected) | |
| 250 | + | } | |
| 251 | + | if seq > rec.snapSeq { | |
| 252 | + | if seq != rec.lastSeq+1 { | |
| 253 | + | return 0, corrupt(off, "record seq %d, want %d", seq, rec.lastSeq+1) | |
| 254 | + | } | |
| 255 | + | if apply != nil { | |
| 256 | + | apply(seq, ownOps(decoded)) | |
| 257 | + | } | |
| 258 | + | rec.lastSeq = seq | |
| 259 | + | } | |
| 260 | + | expected++ | |
| 261 | + | off += n | |
| 262 | + | } | |
| 263 | + | if last && expected-1 < rec.lastSeq { | |
| 264 | + | // Cannot happen unless records acknowledged before the snapshot | |
| 265 | + | // are missing: appending would create a seq gap. | |
| 266 | + | return 0, corrupt(len(data), "log ends at seq %d, want %d", expected-1, rec.lastSeq) | |
| 267 | + | } | |
| 268 | + | return int64(len(data)), nil | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // readFile returns the whole content of the file at path. | |
| 272 | + | func readFile(fx fsys.FS, path string) ([]byte, error) { | |
| 273 | + | f, err := fx.OpenFile(path, os.O_RDONLY, 0) | |
| 274 | + | if err != nil { | |
| 275 | + | return nil, err | |
| 276 | + | } | |
| 277 | + | data, err := readAll(f) | |
| 278 | + | if cerr := f.Close(); err == nil { | |
| 279 | + | err = cerr | |
| 280 | + | } | |
| 281 | + | return data, err | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // readAll returns the whole content of f. | |
| 285 | + | func readAll(f fsys.File) ([]byte, error) { | |
| 286 | + | size, err := f.Size() | |
| 287 | + | if err != nil { | |
| 288 | + | return nil, err | |
| 289 | + | } | |
| 290 | + | data := make([]byte, size) | |
| 291 | + | n, err := f.ReadAt(data, 0) | |
| 292 | + | if n == len(data) { | |
| 293 | + | return data, nil // io.EOF is allowed when the read ends at EOF | |
| 294 | + | } | |
| 295 | + | if err == nil || errors.Is(err, io.EOF) { | |
| 296 | + | err = io.ErrUnexpectedEOF | |
| 297 | + | } | |
| 298 | + | return nil, err | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // createFile durably creates dir/name with the content produced by write: | |
| 302 | + | // name.tmp is written and fsynced, renamed to name, then dir is synced. The | |
| 303 | + | // returned file is open for appending (and reading) at its end. On error the | |
| 304 | + | // temporary file is removed on a best-effort basis. | |
| 305 | + | func createFile(fx fsys.FS, dir, name string, write func(w io.Writer) error) (fsys.File, error) { | |
| 306 | + | tmp := filepath.Join(dir, name+tmpSuffix) | |
| 307 | + | f, err := fx.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) | |
| 308 | + | if err != nil { | |
| 309 | + | return nil, err | |
| 310 | + | } | |
| 311 | + | fail := func(err error) (fsys.File, error) { | |
| 312 | + | _ = f.Close() | |
| 313 | + | _ = fx.Remove(tmp) | |
| 314 | + | return nil, err | |
| 315 | + | } | |
| 316 | + | if err := write(f); err != nil { | |
| 317 | + | return fail(err) | |
| 318 | + | } | |
| 319 | + | if err := f.SyncAll(); err != nil { | |
| 320 | + | return fail(err) | |
| 321 | + | } | |
| 322 | + | if err := fx.Rename(tmp, filepath.Join(dir, name)); err != nil { | |
| 323 | + | return fail(err) | |
| 324 | + | } | |
| 325 | + | if err := fx.SyncDir(dir); err != nil { | |
| 326 | + | _ = f.Close() | |
| 327 | + | return nil, err | |
| 328 | + | } | |
| 329 | + | return f, nil | |
| 330 | + | } | |
| @@ -0,0 +1,330 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package kv | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "log/slog" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "slices" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite-kv/internal/fsys" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | const ( | |||
| 20 | + | lockName = "LOCK" | |||
| 21 | + | snapshotPrefix = "snapshot-" | |||
| 22 | + | logPrefix = "log-" | |||
| 23 | + | tmpSuffix = ".tmp" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | func snapshotName(seq uint64) string { return fmt.Sprintf("%s%016x", snapshotPrefix, seq) } | |||
| 27 | + | func logName(base uint64) string { return fmt.Sprintf("%s%016x", logPrefix, base) } | |||
| 28 | + | ||||
| 29 | + | // parseSeqName parses "<prefix><seq:016x>"; ok is false for any other name. | |||
| 30 | + | func parseSeqName(name, prefix string) (seq uint64, ok bool) { | |||
| 31 | + | if !strings.HasPrefix(name, prefix) { | |||
| 32 | + | return 0, false | |||
| 33 | + | } | |||
| 34 | + | seq, err := strconv.ParseUint(name[len(prefix):], 16, 64) | |||
| 35 | + | if err != nil || fmt.Sprintf("%s%016x", prefix, seq) != name { | |||
| 36 | + | return 0, false | |||
| 37 | + | } | |||
| 38 | + | return seq, true | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // recovered is the state handed to the DB by recoverDir. | |||
| 42 | + | type recovered struct { | |||
| 43 | + | snapSeq uint64 // seq of the loaded snapshot, 0 if none | |||
| 44 | + | lastSeq uint64 // seq of the last applied record (snapSeq if none) | |||
| 45 | + | ||||
| 46 | + | // log is the last log file, opened read-write with appending writes. It | |||
| 47 | + | // is at logBase and holds logSize bytes (header included) after recovery. | |||
| 48 | + | // The caller owns it. | |||
| 49 | + | log fsys.File | |||
| 50 | + | logBase uint64 | |||
| 51 | + | logSize int64 | |||
| 52 | + | ||||
| 53 | + | // lock holds the directory lock; closing it releases the lock. The | |||
| 54 | + | // caller owns it. | |||
| 55 | + | lock io.Closer | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // recoverDir opens the database directory and rebuilds its state: recovery | |||
| 59 | + | // rule 1 (directory, lock, *.tmp files) and rules 2–7. It does not depend on | |||
| 60 | + | // the in-memory tree. | |||
| 61 | + | // | |||
| 62 | + | // load is called once per snapshot entry in ascending key order, only after | |||
| 63 | + | // the whole snapshot file has been validated. apply is called for each log | |||
| 64 | + | // record newer than the snapshot, in seq order. Both receive freshly | |||
| 65 | + | // allocated slices that the callee may retain but must not modify; recovery | |||
| 66 | + | // never reuses them (snapshot entries share one read-only buffer, log ops are | |||
| 67 | + | // copied per record). Either callback may be nil. A nil logger discards logs. | |||
| 68 | + | // | |||
| 69 | + | // On error nothing stays open or locked. ErrLocked and ErrCorrupt are | |||
| 70 | + | // returned wrapped. | |||
| 71 | + | func recoverDir(fx fsys.FS, dir string, logger *slog.Logger, | |||
| 72 | + | load func(key, value []byte, version uint64), | |||
| 73 | + | apply func(seq uint64, ops []op), | |||
| 74 | + | ) (rec recovered, err error) { | |||
| 75 | + | if logger == nil { | |||
| 76 | + | logger = slog.New(slog.DiscardHandler) | |||
| 77 | + | } | |||
| 78 | + | dir = filepath.Clean(dir) | |||
| 79 | + | if err := fx.MkdirAll(dir, 0o700); err != nil { | |||
| 80 | + | return rec, err | |||
| 81 | + | } | |||
| 82 | + | if err := fx.SyncDir(dir); err != nil { | |||
| 83 | + | return rec, err | |||
| 84 | + | } | |||
| 85 | + | if parent := filepath.Dir(dir); parent != dir { | |||
| 86 | + | if err := fx.SyncDir(parent); err != nil { | |||
| 87 | + | return rec, err | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | lock, err := fx.Lock(filepath.Join(dir, lockName)) | |||
| 91 | + | if errors.Is(err, fsys.ErrLocked) { | |||
| 92 | + | return rec, fmt.Errorf("%w: %s", ErrLocked, dir) | |||
| 93 | + | } | |||
| 94 | + | if err != nil { | |||
| 95 | + | return rec, err | |||
| 96 | + | } | |||
| 97 | + | rec.lock = lock | |||
| 98 | + | defer func() { | |||
| 99 | + | if err != nil { | |||
| 100 | + | if rec.log != nil { | |||
| 101 | + | _ = rec.log.Close() | |||
| 102 | + | } | |||
| 103 | + | _ = lock.Close() | |||
| 104 | + | rec = recovered{} | |||
| 105 | + | } | |||
| 106 | + | }() | |||
| 107 | + | ||||
| 108 | + | names, err := fx.ReadDir(dir) | |||
| 109 | + | if err != nil { | |||
| 110 | + | return rec, err | |||
| 111 | + | } | |||
| 112 | + | var snaps, logs []uint64 | |||
| 113 | + | for _, name := range names { | |||
| 114 | + | if strings.HasSuffix(name, tmpSuffix) { | |||
| 115 | + | if err := fx.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { | |||
| 116 | + | return rec, err | |||
| 117 | + | } | |||
| 118 | + | } else if seq, ok := parseSeqName(name, snapshotPrefix); ok { | |||
| 119 | + | snaps = append(snaps, seq) | |||
| 120 | + | } else if base, ok := parseSeqName(name, logPrefix); ok { | |||
| 121 | + | logs = append(logs, base) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if err := fx.SyncDir(dir); err != nil { | |||
| 125 | + | return rec, err | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Rule 2: load the highest-seq snapshot that validates. | |||
| 129 | + | slices.Sort(snaps) | |||
| 130 | + | for i := len(snaps) - 1; i >= 0; i-- { | |||
| 131 | + | path := filepath.Join(dir, snapshotName(snaps[i])) | |||
| 132 | + | data, err := readFile(fx, path) | |||
| 133 | + | if err != nil { | |||
| 134 | + | return rec, err | |||
| 135 | + | } | |||
| 136 | + | view, err := openSnapshot(data) | |||
| 137 | + | if err == nil && view.seq != snaps[i] { | |||
| 138 | + | err = fmt.Errorf("%w: snapshot seq %d does not match its name", ErrCorrupt, view.seq) | |||
| 139 | + | } | |||
| 140 | + | if err != nil { | |||
| 141 | + | logger.Warn("kv: ignoring invalid snapshot", "file", path, "err", err) | |||
| 142 | + | continue | |||
| 143 | + | } | |||
| 144 | + | if load != nil { | |||
| 145 | + | view.each(load) | |||
| 146 | + | } | |||
| 147 | + | rec.snapSeq = view.seq | |||
| 148 | + | break | |||
| 149 | + | } | |||
| 150 | + | rec.lastSeq = rec.snapSeq | |||
| 151 | + | ||||
| 152 | + | // Rules 3–6: replay the logs in base order. | |||
| 153 | + | slices.Sort(logs) | |||
| 154 | + | for i, base := range logs { | |||
| 155 | + | last := i == len(logs)-1 | |||
| 156 | + | path := filepath.Join(dir, logName(base)) | |||
| 157 | + | if !last { | |||
| 158 | + | data, err := readFile(fx, path) | |||
| 159 | + | if err != nil { | |||
| 160 | + | return rec, err | |||
| 161 | + | } | |||
| 162 | + | if _, err := replayLog(nil, data, path, base, &rec, logger, apply); err != nil { | |||
| 163 | + | return rec, err | |||
| 164 | + | } | |||
| 165 | + | continue | |||
| 166 | + | } | |||
| 167 | + | f, err := fx.OpenFile(path, os.O_RDWR, 0) | |||
| 168 | + | if err != nil { | |||
| 169 | + | return rec, err | |||
| 170 | + | } | |||
| 171 | + | data, err := readAll(f) | |||
| 172 | + | if err == nil { | |||
| 173 | + | rec.logSize, err = replayLog(f, data, path, base, &rec, logger, apply) | |||
| 174 | + | } | |||
| 175 | + | if err != nil { | |||
| 176 | + | _ = f.Close() | |||
| 177 | + | return rec, err | |||
| 178 | + | } | |||
| 179 | + | rec.log, rec.logBase = f, base | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // Rule 7: no log file at all. | |||
| 183 | + | if rec.log == nil { | |||
| 184 | + | name := logName(rec.snapSeq) | |||
| 185 | + | header := appendLogHeader(nil, rec.snapSeq) | |||
| 186 | + | f, err := createFile(fx, dir, name, func(w io.Writer) error { | |||
| 187 | + | _, err := w.Write(header) | |||
| 188 | + | return err | |||
| 189 | + | }) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return rec, err | |||
| 192 | + | } | |||
| 193 | + | rec.log, rec.logBase, rec.logSize = f, rec.snapSeq, logHeaderSize | |||
| 194 | + | } | |||
| 195 | + | return rec, nil | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // replayLog applies the records of the log file contents data (rules 3–6) | |||
| 199 | + | // and updates rec.lastSeq. f is nil for a log that is not the last one; for | |||
| 200 | + | // the last log it is used to truncate a torn tail. It returns the size of the | |||
| 201 | + | // log after recovery. | |||
| 202 | + | func replayLog(f fsys.File, data []byte, path string, base uint64, rec *recovered, | |||
| 203 | + | logger *slog.Logger, apply func(seq uint64, ops []op), | |||
| 204 | + | ) (int64, error) { | |||
| 205 | + | last := f != nil | |||
| 206 | + | corrupt := func(off int, format string, args ...any) error { | |||
| 207 | + | return fmt.Errorf("%w: %s at offset %d: %s", ErrCorrupt, path, off, fmt.Sprintf(format, args...)) | |||
| 208 | + | } | |||
| 209 | + | hdrBase, err := decodeLogHeader(data) | |||
| 210 | + | if err != nil { | |||
| 211 | + | return 0, fmt.Errorf("%s: %w", path, err) | |||
| 212 | + | } | |||
| 213 | + | if hdrBase != base { | |||
| 214 | + | return 0, corrupt(0, "header base %d does not match the file name", hdrBase) | |||
| 215 | + | } | |||
| 216 | + | if base > rec.lastSeq { | |||
| 217 | + | return 0, corrupt(0, "base %d is above the last recovered seq %d", base, rec.lastSeq) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | var ops []op | |||
| 221 | + | expected := base + 1 // seq of the next record of this file | |||
| 222 | + | off := logHeaderSize | |||
| 223 | + | for off < len(data) { | |||
| 224 | + | seq, decoded, n, err := decodeRecord(data[off:], ops) | |||
| 225 | + | if err != nil { | |||
| 226 | + | if !last { | |||
| 227 | + | return 0, corrupt(off, "%v", err) | |||
| 228 | + | } | |||
| 229 | + | // Rule 6: a readable next record means acknowledged data | |||
| 230 | + | // lies beyond the damage. | |||
| 231 | + | if total, ok := peekRecord(data[off:]); ok && off+total < len(data) { | |||
| 232 | + | nseq, _, _, nerr := decodeRecord(data[off+total:], nil) | |||
| 233 | + | if nerr == nil && nseq == expected+1 { | |||
| 234 | + | return 0, corrupt(off, "%v, followed by a valid record", err) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | if err := f.Truncate(int64(off)); err != nil { | |||
| 238 | + | return 0, err | |||
| 239 | + | } | |||
| 240 | + | if err := f.SyncAll(); err != nil { | |||
| 241 | + | return 0, err | |||
| 242 | + | } | |||
| 243 | + | logger.Warn("kv: dropped torn log tail", "file", path, "offset", off, "bytes", len(data)-off) | |||
| 244 | + | data = data[:off] | |||
| 245 | + | break | |||
| 246 | + | } | |||
| 247 | + | ops = decoded[:0] | |||
| 248 | + | if seq != expected { | |||
| 249 | + | return 0, corrupt(off, "record seq %d, want %d", seq, expected) | |||
| 250 | + | } | |||
| 251 | + | if seq > rec.snapSeq { | |||
| 252 | + | if seq != rec.lastSeq+1 { | |||
| 253 | + | return 0, corrupt(off, "record seq %d, want %d", seq, rec.lastSeq+1) | |||
| 254 | + | } | |||
| 255 | + | if apply != nil { | |||
| 256 | + | apply(seq, ownOps(decoded)) | |||
| 257 | + | } | |||
| 258 | + | rec.lastSeq = seq | |||
| 259 | + | } | |||
| 260 | + | expected++ | |||
| 261 | + | off += n | |||
| 262 | + | } | |||
| 263 | + | if last && expected-1 < rec.lastSeq { | |||
| 264 | + | // Cannot happen unless records acknowledged before the snapshot | |||
| 265 | + | // are missing: appending would create a seq gap. | |||
| 266 | + | return 0, corrupt(len(data), "log ends at seq %d, want %d", expected-1, rec.lastSeq) | |||
| 267 | + | } | |||
| 268 | + | return int64(len(data)), nil | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // readFile returns the whole content of the file at path. | |||
| 272 | + | func readFile(fx fsys.FS, path string) ([]byte, error) { | |||
| 273 | + | f, err := fx.OpenFile(path, os.O_RDONLY, 0) | |||
| 274 | + | if err != nil { | |||
| 275 | + | return nil, err | |||
| 276 | + | } | |||
| 277 | + | data, err := readAll(f) | |||
| 278 | + | if cerr := f.Close(); err == nil { | |||
| 279 | + | err = cerr | |||
| 280 | + | } | |||
| 281 | + | return data, err | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // readAll returns the whole content of f. | |||
| 285 | + | func readAll(f fsys.File) ([]byte, error) { | |||
| 286 | + | size, err := f.Size() | |||
| 287 | + | if err != nil { | |||
| 288 | + | return nil, err | |||
| 289 | + | } | |||
| 290 | + | data := make([]byte, size) | |||
| 291 | + | n, err := f.ReadAt(data, 0) | |||
| 292 | + | if n == len(data) { | |||
| 293 | + | return data, nil // io.EOF is allowed when the read ends at EOF | |||
| 294 | + | } | |||
| 295 | + | if err == nil || errors.Is(err, io.EOF) { | |||
| 296 | + | err = io.ErrUnexpectedEOF | |||
| 297 | + | } | |||
| 298 | + | return nil, err | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // createFile durably creates dir/name with the content produced by write: | |||
| 302 | + | // name.tmp is written and fsynced, renamed to name, then dir is synced. The | |||
| 303 | + | // returned file is open for appending (and reading) at its end. On error the | |||
| 304 | + | // temporary file is removed on a best-effort basis. | |||
| 305 | + | func createFile(fx fsys.FS, dir, name string, write func(w io.Writer) error) (fsys.File, error) { | |||
| 306 | + | tmp := filepath.Join(dir, name+tmpSuffix) | |||
| 307 | + | f, err := fx.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) | |||
| 308 | + | if err != nil { | |||
| 309 | + | return nil, err | |||
| 310 | + | } | |||
| 311 | + | fail := func(err error) (fsys.File, error) { | |||
| 312 | + | _ = f.Close() | |||
| 313 | + | _ = fx.Remove(tmp) | |||
| 314 | + | return nil, err | |||
| 315 | + | } | |||
| 316 | + | if err := write(f); err != nil { | |||
| 317 | + | return fail(err) | |||
| 318 | + | } | |||
| 319 | + | if err := f.SyncAll(); err != nil { | |||
| 320 | + | return fail(err) | |||
| 321 | + | } | |||
| 322 | + | if err := fx.Rename(tmp, filepath.Join(dir, name)); err != nil { | |||
| 323 | + | return fail(err) | |||
| 324 | + | } | |||
| 325 | + | if err := fx.SyncDir(dir); err != nil { | |||
| 326 | + | _ = f.Close() | |||
| 327 | + | return nil, err | |||
| 328 | + | } | |||
| 329 | + | return f, nil | |||
| 330 | + | } | |||
A recover_test.go +565 -0
| @@ -0,0 +1,565 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package kv | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "log/slog" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite-kv/internal/fsys" | |
| 16 | + | "bnema.dev/ermite-kv/internal/fsys/faultfs" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const testDir = "/db" | |
| 20 | + | ||
| 21 | + | // state is the replayed content of a database. | |
| 22 | + | type state struct { | |
| 23 | + | m map[string]string | |
| 24 | + | versions map[string]uint64 | |
| 25 | + | seqs []uint64 // seqs passed to apply, in order | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (s *state) load(k, v []byte, ver uint64) { | |
| 29 | + | s.m[string(k)] = string(v) | |
| 30 | + | s.versions[string(k)] = ver | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (s *state) apply(seq uint64, ops []op) { | |
| 34 | + | s.seqs = append(s.seqs, seq) | |
| 35 | + | for _, o := range ops { | |
| 36 | + | switch o.kind { | |
| 37 | + | case opPut: | |
| 38 | + | s.m[string(o.key)] = string(o.value) | |
| 39 | + | s.versions[string(o.key)] = seq | |
| 40 | + | case opDelete: | |
| 41 | + | delete(s.m, string(o.key)) | |
| 42 | + | delete(s.versions, string(o.key)) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func recoverState(t *testing.T, fx fsys.FS, logger *slog.Logger) (*state, recovered, error) { | |
| 48 | + | t.Helper() | |
| 49 | + | s := &state{m: map[string]string{}, versions: map[string]uint64{}} | |
| 50 | + | rec, err := recoverDir(fx, testDir, logger, s.load, s.apply) | |
| 51 | + | return s, rec, err | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // mustRecover recovers, checks the invariants of a successful recovery and | |
| 55 | + | // releases the lock and the log file. | |
| 56 | + | func mustRecover(t *testing.T, fx fsys.FS) (*state, recovered) { | |
| 57 | + | t.Helper() | |
| 58 | + | s, rec, err := recoverState(t, fx, nil) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatalf("recoverDir: %v", err) | |
| 61 | + | } | |
| 62 | + | size, err := rec.log.Size() | |
| 63 | + | if err != nil || size != rec.logSize { | |
| 64 | + | t.Fatalf("log size = %d (err %v), recovered.logSize = %d", size, err, rec.logSize) | |
| 65 | + | } | |
| 66 | + | t.Cleanup(func() { _ = rec.log.Close(); _ = rec.lock.Close() }) | |
| 67 | + | return s, rec | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // release closes the log file and the lock of a recovery result. | |
| 71 | + | func release(rec recovered) { | |
| 72 | + | _ = rec.log.Close() | |
| 73 | + | _ = rec.lock.Close() | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func putOp(k, v string) op { return op{kind: opPut, key: []byte(k), value: []byte(v)} } | |
| 77 | + | func delOp(k string) op { return op{kind: opDelete, key: []byte(k)} } | |
| 78 | + | ||
| 79 | + | // logBytes builds a log file: header for base, then one record per batch with | |
| 80 | + | // consecutive seqs starting at base+1. | |
| 81 | + | func logBytes(base uint64, batches ...[]op) []byte { | |
| 82 | + | b := appendLogHeader(nil, base) | |
| 83 | + | for i, ops := range batches { | |
| 84 | + | b = appendRecord(b, base+1+uint64(i), ops) | |
| 85 | + | } | |
| 86 | + | return b | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func put(fx *faultfs.FS, name string, data []byte) { fx.WriteFile(filepath.Join(testDir, name), data) } | |
| 90 | + | ||
| 91 | + | func snapshotBytes(t *testing.T, seq uint64, entries ...snapEntry) []byte { | |
| 92 | + | t.Helper() | |
| 93 | + | return encodeSnapshot(t, seq, entries) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func entry(k, v string, ver uint64) snapEntry { return snapEntry{[]byte(k), []byte(v), ver} } | |
| 97 | + | ||
| 98 | + | func wantState(t *testing.T, s *state, m map[string]string) { | |
| 99 | + | t.Helper() | |
| 100 | + | if len(s.m) != len(m) { | |
| 101 | + | t.Fatalf("state = %v, want %v", s.m, m) | |
| 102 | + | } | |
| 103 | + | for k, v := range m { | |
| 104 | + | if got, ok := s.m[k]; !ok || got != v { | |
| 105 | + | t.Fatalf("state = %v, want %v", s.m, m) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestRecoverEmptyDirCreatesLog(t *testing.T) { | |
| 111 | + | fx := faultfs.New() | |
| 112 | + | s, rec := mustRecover(t, fx) | |
| 113 | + | wantState(t, s, nil) | |
| 114 | + | if rec.snapSeq != 0 || rec.lastSeq != 0 || rec.logBase != 0 || rec.logSize != logHeaderSize { | |
| 115 | + | t.Fatalf("rec = %+v", rec) | |
| 116 | + | } | |
| 117 | + | // The new log must survive a power loss: file creation rules. | |
| 118 | + | _ = rec.log.Close() | |
| 119 | + | _ = rec.lock.Close() | |
| 120 | + | fx.PowerLoss(faultfs.PowerLossOptions{}) | |
| 121 | + | names, err := fx.ReadDir(testDir) | |
| 122 | + | if err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | if got := strings.Join(names, ","); got != "LOCK,"+logName(0) && got != logName(0) { | |
| 126 | + | t.Fatalf("after power loss: %v", names) | |
| 127 | + | } | |
| 128 | + | data, _ := fx.ReadFile(filepath.Join(testDir, logName(0))) | |
| 129 | + | if !bytes.Equal(data, appendLogHeader(nil, 0)) { | |
| 130 | + | t.Fatalf("log content %x", data) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestRecoverLockedAndRelease(t *testing.T) { | |
| 135 | + | fx := faultfs.New() | |
| 136 | + | _, rec := mustRecover(t, fx) | |
| 137 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrLocked) { | |
| 138 | + | t.Fatalf("second recover: err = %v, want ErrLocked", err) | |
| 139 | + | } | |
| 140 | + | _ = rec.log.Close() | |
| 141 | + | _ = rec.lock.Close() | |
| 142 | + | _, rec2, err := recoverState(t, fx, nil) | |
| 143 | + | if err != nil { | |
| 144 | + | t.Fatalf("recover after release: %v", err) | |
| 145 | + | } | |
| 146 | + | _ = rec2.log.Close() | |
| 147 | + | _ = rec2.lock.Close() | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestRecoverReleasesLockOnError(t *testing.T) { | |
| 151 | + | fx := faultfs.New() | |
| 152 | + | put(fx, logName(0), []byte("garbage")) | |
| 153 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |
| 154 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |
| 155 | + | } | |
| 156 | + | l, err := fx.Lock(filepath.Join(testDir, lockName)) | |
| 157 | + | if err != nil { | |
| 158 | + | t.Fatalf("lock still held after failure: %v", err) | |
| 159 | + | } | |
| 160 | + | _ = l.Close() | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestRecoverCleanLog(t *testing.T) { | |
| 164 | + | fx := faultfs.New() | |
| 165 | + | data := logBytes(0, | |
| 166 | + | []op{putOp("a", "1"), putOp("b", "2")}, | |
| 167 | + | []op{delOp("a"), putOp("c", "3")}, | |
| 168 | + | []op{putOp("b", "4")}, | |
| 169 | + | ) | |
| 170 | + | put(fx, logName(0), data) | |
| 171 | + | s, rec := mustRecover(t, fx) | |
| 172 | + | wantState(t, s, map[string]string{"b": "4", "c": "3"}) | |
| 173 | + | if got := fmt.Sprint(s.seqs); got != "[1 2 3]" { | |
| 174 | + | t.Fatalf("applied seqs %s", got) | |
| 175 | + | } | |
| 176 | + | if s.versions["b"] != 3 || s.versions["c"] != 2 { | |
| 177 | + | t.Fatalf("versions = %v", s.versions) | |
| 178 | + | } | |
| 179 | + | if rec.snapSeq != 0 || rec.lastSeq != 3 || rec.logBase != 0 || rec.logSize != int64(len(data)) { | |
| 180 | + | t.Fatalf("rec = %+v", rec) | |
| 181 | + | } | |
| 182 | + | // Appends go to the end of the recovered log. | |
| 183 | + | if _, err := rec.log.Write([]byte("x")); err != nil { | |
| 184 | + | t.Fatal(err) | |
| 185 | + | } | |
| 186 | + | if size, _ := rec.log.Size(); size != int64(len(data))+1 { | |
| 187 | + | t.Fatalf("size after append = %d", size) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestRecoverDeletesTmpFiles(t *testing.T) { | |
| 192 | + | fx := faultfs.New() | |
| 193 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |
| 194 | + | put(fx, snapshotName(5)+tmpSuffix, []byte("partial")) | |
| 195 | + | put(fx, logName(5)+tmpSuffix, []byte("partial")) | |
| 196 | + | s, _ := mustRecover(t, fx) | |
| 197 | + | wantState(t, s, map[string]string{"a": "1"}) | |
| 198 | + | names, _ := fx.ReadDir(testDir) | |
| 199 | + | for _, n := range names { | |
| 200 | + | if strings.HasSuffix(n, tmpSuffix) { | |
| 201 | + | t.Fatalf("tmp file %s left", n) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | // Deletion is durable. | |
| 205 | + | fx.PowerLoss(faultfs.PowerLossOptions{}) | |
| 206 | + | names, _ = fx.ReadDir(testDir) | |
| 207 | + | for _, n := range names { | |
| 208 | + | if strings.HasSuffix(n, tmpSuffix) { | |
| 209 | + | t.Fatalf("tmp file %s back after power loss", n) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestRecoverTornTail(t *testing.T) { | |
| 215 | + | good := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")}) | |
| 216 | + | next := appendRecord(nil, 3, []op{putOp("c", "3")}) | |
| 217 | + | cases := map[string][]byte{ | |
| 218 | + | "short frame": next[:3], | |
| 219 | + | "short record": next[:len(next)-2], | |
| 220 | + | "zero-filled tail": make([]byte, 100), | |
| 221 | + | "garbage": []byte("this is not a record at all, just junk bytes"), | |
| 222 | + | "bad crc": append(append([]byte(nil), next[:len(next)-1]...), next[len(next)-1]^1), | |
| 223 | + | "huge length": {0xff, 0xff, 0xff, 0x7f, 1, 2, 3, 4, 5, 6, 7, 8}, | |
| 224 | + | "one byte": {0x01}, | |
| 225 | + | } | |
| 226 | + | for name, tail := range cases { | |
| 227 | + | t.Run(name, func(t *testing.T) { | |
| 228 | + | fx := faultfs.New() | |
| 229 | + | put(fx, logName(0), append(append([]byte(nil), good...), tail...)) | |
| 230 | + | var logs bytes.Buffer | |
| 231 | + | s, rec, err := recoverState(t, fx, slog.New(slog.NewTextHandler(&logs, nil))) | |
| 232 | + | if err != nil { | |
| 233 | + | t.Fatal(err) | |
| 234 | + | } | |
| 235 | + | defer release(rec) | |
| 236 | + | wantState(t, s, map[string]string{"a": "1", "b": "2"}) | |
| 237 | + | if rec.lastSeq != 2 || rec.logSize != int64(len(good)) { | |
| 238 | + | t.Fatalf("rec = %+v, want size %d", rec, len(good)) | |
| 239 | + | } | |
| 240 | + | if got, _ := fx.ReadFile(filepath.Join(testDir, logName(0))); !bytes.Equal(got, good) { | |
| 241 | + | t.Fatalf("log not truncated: %d bytes, want %d", len(got), len(good)) | |
| 242 | + | } | |
| 243 | + | if got, _ := fx.DurableFile(filepath.Join(testDir, logName(0))); !bytes.Equal(got, good) { | |
| 244 | + | t.Fatal("truncation not synced") | |
| 245 | + | } | |
| 246 | + | if !strings.Contains(logs.String(), "dropped torn log tail") { | |
| 247 | + | t.Fatalf("no warning logged: %q", logs.String()) | |
| 248 | + | } | |
| 249 | + | }) | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestRecoverTornTailOnlyHeaderSurvives(t *testing.T) { | |
| 254 | + | fx := faultfs.New() | |
| 255 | + | put(fx, logName(0), append(logBytes(0), make([]byte, 7)...)) | |
| 256 | + | s, rec := mustRecover(t, fx) | |
| 257 | + | wantState(t, s, nil) | |
| 258 | + | if rec.logSize != logHeaderSize { | |
| 259 | + | t.Fatalf("logSize = %d", rec.logSize) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func TestRecoverDamageFollowedByValidRecord(t *testing.T) { | |
| 264 | + | r1 := appendRecord(nil, 1, []op{putOp("a", "1")}) | |
| 265 | + | r2 := appendRecord(nil, 2, []op{putOp("b", "2")}) | |
| 266 | + | r3 := appendRecord(nil, 3, []op{putOp("c", "3")}) | |
| 267 | + | flip := func(b []byte, i int) []byte { | |
| 268 | + | c := append([]byte(nil), b...) | |
| 269 | + | c[i] ^= 0xff | |
| 270 | + | return c | |
| 271 | + | } | |
| 272 | + | hdr := appendLogHeader(nil, 0) | |
| 273 | + | cat := func(parts ...[]byte) []byte { return bytes.Join(parts, nil) } | |
| 274 | + | cases := map[string][]byte{ | |
| 275 | + | "bad crc in middle record": cat(hdr, r1, flip(r2, len(r2)-1), r3), | |
| 276 | + | "bad payload in first record": cat(hdr, flip(r1, 9), r2), | |
| 277 | + | "bad checksum field": cat(hdr, r1, flip(r2, 4), r3), | |
| 278 | + | "damaged record before last": cat(hdr, flip(r1, len(r1)-1), r2, r3), | |
| 279 | + | "record with wrong count": cat(hdr, rawRecord(rawPayload(1, 5, opDelete, 1, 'k')), r2), | |
| 280 | + | "corrupt but len looks intact": cat(hdr, r1, flip(r2, 12), r3), | |
| 281 | + | } | |
| 282 | + | for name, data := range cases { | |
| 283 | + | t.Run(name, func(t *testing.T) { | |
| 284 | + | fx := faultfs.New() | |
| 285 | + | put(fx, logName(0), data) | |
| 286 | + | _, _, err := recoverState(t, fx, nil) | |
| 287 | + | if !errors.Is(err, ErrCorrupt) { | |
| 288 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |
| 289 | + | } | |
| 290 | + | // The file must be left untouched. | |
| 291 | + | if got, _ := fx.ReadFile(filepath.Join(testDir, logName(0))); !bytes.Equal(got, data) { | |
| 292 | + | t.Fatal("corrupt log was modified") | |
| 293 | + | } | |
| 294 | + | }) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestRecoverDamageInOlderLog(t *testing.T) { | |
| 299 | + | old := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")}) | |
| 300 | + | cases := map[string][]byte{ | |
| 301 | + | "torn tail": old[:len(old)-3], | |
| 302 | + | "bad crc": append(append([]byte(nil), old[:len(old)-1]...), old[len(old)-1]^1), | |
| 303 | + | "zero tail": append(append([]byte(nil), old...), make([]byte, 30)...), | |
| 304 | + | "extra bytes": append(append([]byte(nil), old...), 1, 2, 3), | |
| 305 | + | } | |
| 306 | + | for name, data := range cases { | |
| 307 | + | t.Run(name, func(t *testing.T) { | |
| 308 | + | fx := faultfs.New() | |
| 309 | + | put(fx, logName(0), data) | |
| 310 | + | put(fx, logName(2), logBytes(2, []op{putOp("c", "3")})) | |
| 311 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |
| 312 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |
| 313 | + | } | |
| 314 | + | }) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestRecoverBadLogHeader(t *testing.T) { | |
| 319 | + | good := logBytes(0, []op{putOp("a", "1")}) | |
| 320 | + | flip := func(i int) []byte { | |
| 321 | + | c := append([]byte(nil), good...) | |
| 322 | + | c[i] ^= 1 | |
| 323 | + | return c | |
| 324 | + | } | |
| 325 | + | cases := map[string]struct { | |
| 326 | + | name string | |
| 327 | + | data []byte | |
| 328 | + | }{ | |
| 329 | + | "empty file": {logName(0), nil}, | |
| 330 | + | "short header": {logName(0), good[:10]}, | |
| 331 | + | "bad magic": {logName(0), flip(0)}, | |
| 332 | + | "bad crc": {logName(0), flip(18)}, | |
| 333 | + | "flipped base": {logName(0), flip(9)}, | |
| 334 | + | "base != name": {logName(7), good}, | |
| 335 | + | "all zero header": {logName(0), make([]byte, logHeaderSize)}, | |
| 336 | + | } | |
| 337 | + | for name, c := range cases { | |
| 338 | + | t.Run(name, func(t *testing.T) { | |
| 339 | + | fx := faultfs.New() | |
| 340 | + | put(fx, c.name, c.data) | |
| 341 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |
| 342 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |
| 343 | + | } | |
| 344 | + | }) | |
| 345 | + | } | |
| 346 | + | t.Run("bad header in older log", func(t *testing.T) { | |
| 347 | + | fx := faultfs.New() | |
| 348 | + | put(fx, logName(0), good[:10]) | |
| 349 | + | put(fx, logName(1), logBytes(1, []op{putOp("b", "2")})) | |
| 350 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |
| 351 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |
| 352 | + | } | |
| 353 | + | }) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestRecoverSeqGap(t *testing.T) { | |
| 357 | + | cases := map[string]func(fx *faultfs.FS){ | |
| 358 | + | "gap inside a log": func(fx *faultfs.FS) { | |
| 359 | + | b := appendLogHeader(nil, 0) | |
| 360 | + | b = appendRecord(b, 1, []op{putOp("a", "1")}) | |
| 361 | + | b = appendRecord(b, 3, []op{putOp("b", "2")}) | |
| 362 | + | put(fx, logName(0), b) | |
| 363 | + | }, | |
| 364 | + | "first record not base+1": func(fx *faultfs.FS) { | |
| 365 | + | b := appendRecord(appendLogHeader(nil, 0), 2, []op{putOp("a", "1")}) | |
| 366 | + | put(fx, logName(0), b) | |
| 367 | + | }, | |
| 368 | + | "gap between logs": func(fx *faultfs.FS) { | |
| 369 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |
| 370 | + | put(fx, logName(2), logBytes(2, []op{putOp("c", "3")})) | |
| 371 | + | }, | |
| 372 | + | "first log starts after seq 0": func(fx *faultfs.FS) { | |
| 373 | + | put(fx, logName(4), logBytes(4, []op{putOp("a", "1")})) | |
| 374 | + | }, | |
| 375 | + | "log after snapshot has a gap": func(fx *faultfs.FS) { | |
| 376 | + | put(fx, snapshotName(3), snapshotBytes(t, 3, entry("a", "1", 3))) | |
| 377 | + | put(fx, logName(5), logBytes(5, []op{putOp("b", "2")})) | |
| 378 | + | }, | |
| 379 | + | "logs end before snapshot": func(fx *faultfs.FS) { | |
| 380 | + | put(fx, snapshotName(5), snapshotBytes(t, 5, entry("a", "1", 3))) | |
| 381 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |
| 382 | + | }, | |
| 383 | + | "duplicate seq": func(fx *faultfs.FS) { | |
| 384 | + | b := appendLogHeader(nil, 0) | |
| 385 | + | b = appendRecord(b, 1, []op{putOp("a", "1")}) | |
| 386 | + | b = appendRecord(b, 1, []op{putOp("b", "2")}) | |
| 387 | + | put(fx, logName(0), b) | |
| 388 | + | }, | |
| 389 | + | } | |
| 390 | + | for name, setup := range cases { | |
| 391 | + | t.Run(name, func(t *testing.T) { | |
| 392 | + | fx := faultfs.New() | |
| 393 | + | setup(fx) | |
| 394 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |
| 395 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |
| 396 | + | } | |
| 397 | + | }) | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func TestRecoverSnapshotThenLogs(t *testing.T) { | |
| 402 | + | fx := faultfs.New() | |
| 403 | + | put(fx, snapshotName(3), snapshotBytes(t, 3, entry("a", "1", 1), entry("b", "2", 3))) | |
| 404 | + | // log-0 still holds records already in the snapshot; log-3 continues it. | |
| 405 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "old")}, []op{putOp("x", "gone")}, []op{putOp("b", "2")})) | |
| 406 | + | put(fx, logName(3), logBytes(3, []op{putOp("c", "3")}, []op{delOp("a")})) | |
| 407 | + | s, rec := mustRecover(t, fx) | |
| 408 | + | wantState(t, s, map[string]string{"b": "2", "c": "3"}) | |
| 409 | + | if got := fmt.Sprint(s.seqs); got != "[4 5]" { | |
| 410 | + | t.Fatalf("applied seqs %s", got) | |
| 411 | + | } | |
| 412 | + | if s.versions["b"] != 3 || s.versions["c"] != 4 { | |
| 413 | + | t.Fatalf("versions = %v", s.versions) | |
| 414 | + | } | |
| 415 | + | if rec.snapSeq != 3 || rec.lastSeq != 5 || rec.logBase != 3 { | |
| 416 | + | t.Fatalf("rec = %+v", rec) | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func TestRecoverSnapshotWithoutLogCreatesLog(t *testing.T) { | |
| 421 | + | fx := faultfs.New() | |
| 422 | + | put(fx, snapshotName(7), snapshotBytes(t, 7, entry("a", "1", 7))) | |
| 423 | + | s, rec := mustRecover(t, fx) | |
| 424 | + | wantState(t, s, map[string]string{"a": "1"}) | |
| 425 | + | if rec.snapSeq != 7 || rec.lastSeq != 7 || rec.logBase != 7 || rec.logSize != logHeaderSize { | |
| 426 | + | t.Fatalf("rec = %+v", rec) | |
| 427 | + | } | |
| 428 | + | if _, ok := fx.ReadFile(filepath.Join(testDir, logName(7))); !ok { | |
| 429 | + | t.Fatal("log-7 not created") | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | func TestRecoverInvalidNewestSnapshotFallsBack(t *testing.T) { | |
| 434 | + | good := snapshotBytes(t, 2, entry("a", "1", 1), entry("b", "2", 2)) | |
| 435 | + | newer := snapshotBytes(t, 4, entry("a", "1", 1), entry("z", "9", 4)) | |
| 436 | + | truncated := newer[:len(newer)-2] | |
| 437 | + | flipped := append([]byte(nil), newer...) | |
| 438 | + | flipped[30] ^= 1 | |
| 439 | + | wrongName := snapshotBytes(t, 3, entry("a", "1", 1)) | |
| 440 | + | for name, bad := range map[string][]byte{"truncated": truncated, "flipped": flipped, "empty": nil, "seq/name mismatch": wrongName} { | |
| 441 | + | t.Run(name, func(t *testing.T) { | |
| 442 | + | fx := faultfs.New() | |
| 443 | + | put(fx, snapshotName(2), good) | |
| 444 | + | put(fx, snapshotName(4), bad) | |
| 445 | + | put(fx, logName(2), logBytes(2, []op{putOp("c", "3")}, []op{putOp("d", "4")}, []op{delOp("a")})) | |
| 446 | + | var logs bytes.Buffer | |
| 447 | + | s, rec, err := recoverState(t, fx, slog.New(slog.NewTextHandler(&logs, nil))) | |
| 448 | + | if err != nil { | |
| 449 | + | t.Fatal(err) | |
| 450 | + | } | |
| 451 | + | defer release(rec) | |
| 452 | + | wantState(t, s, map[string]string{"b": "2", "c": "3", "d": "4"}) | |
| 453 | + | if rec.snapSeq != 2 || rec.lastSeq != 5 { | |
| 454 | + | t.Fatalf("rec = %+v", rec) | |
| 455 | + | } | |
| 456 | + | if !strings.Contains(logs.String(), "invalid snapshot") { | |
| 457 | + | t.Fatalf("fallback not logged: %q", logs.String()) | |
| 458 | + | } | |
| 459 | + | }) | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func TestRecoverInvalidSnapshotNoOtherFallsBackToEmpty(t *testing.T) { | |
| 464 | + | fx := faultfs.New() | |
| 465 | + | put(fx, snapshotName(3), []byte("junk")) | |
| 466 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |
| 467 | + | s, rec := mustRecover(t, fx) | |
| 468 | + | wantState(t, s, map[string]string{"a": "1"}) | |
| 469 | + | if rec.snapSeq != 0 || rec.lastSeq != 1 { | |
| 470 | + | t.Fatalf("rec = %+v", rec) | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func TestRecoverNoSnapshotStartsAtZero(t *testing.T) { | |
| 475 | + | fx := faultfs.New() | |
| 476 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |
| 477 | + | put(fx, logName(1), logBytes(1, []op{putOp("b", "2")})) | |
| 478 | + | put(fx, "unrelated.txt", []byte("ignored")) | |
| 479 | + | put(fx, "log-xyz", []byte("ignored")) | |
| 480 | + | s, rec := mustRecover(t, fx) | |
| 481 | + | wantState(t, s, map[string]string{"a": "1", "b": "2"}) | |
| 482 | + | if rec.snapSeq != 0 || rec.lastSeq != 2 || rec.logBase != 1 { | |
| 483 | + | t.Fatalf("rec = %+v", rec) | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func TestRecoverSnapshotLoadOrderAndOwnership(t *testing.T) { | |
| 488 | + | fx := faultfs.New() | |
| 489 | + | put(fx, snapshotName(1), snapshotBytes(t, 1, entry("a", "1", 1), entry("b", "2", 1), entry("c", "3", 1))) | |
| 490 | + | var keys []string | |
| 491 | + | var held [][]byte | |
| 492 | + | _, err := recoverDir(fx, testDir, nil, func(k, v []byte, ver uint64) { | |
| 493 | + | keys = append(keys, string(k)) | |
| 494 | + | held = append(held, k, v) | |
| 495 | + | }, nil) | |
| 496 | + | if err != nil { | |
| 497 | + | t.Fatal(err) | |
| 498 | + | } | |
| 499 | + | if strings.Join(keys, "") != "abc" { | |
| 500 | + | t.Fatalf("keys = %v", keys) | |
| 501 | + | } | |
| 502 | + | var all []byte | |
| 503 | + | for _, h := range held { | |
| 504 | + | all = append(all, h...) | |
| 505 | + | } | |
| 506 | + | if string(all) != "a1b2c3" { | |
| 507 | + | t.Fatalf("retained slices changed: %q", all) | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | func TestRecoverPowerLossAfterRecovery(t *testing.T) { | |
| 512 | + | // A fresh directory survives a power loss right after recovery and | |
| 513 | + | // reopens cleanly with the same state. | |
| 514 | + | fx := faultfs.New() | |
| 515 | + | _, rec := mustRecover(t, fx) | |
| 516 | + | _ = rec.log.Close() | |
| 517 | + | _ = rec.lock.Close() | |
| 518 | + | fx.PowerLoss(faultfs.PowerLossOptions{}) | |
| 519 | + | s, rec2 := mustRecover(t, fx) | |
| 520 | + | wantState(t, s, nil) | |
| 521 | + | if rec2.logBase != 0 { | |
| 522 | + | t.Fatalf("rec = %+v", rec2) | |
| 523 | + | } | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | func TestRecoverOSFilesystem(t *testing.T) { | |
| 527 | + | dir := filepath.Join(t.TempDir(), "nested", "db") | |
| 528 | + | data := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")}) | |
| 529 | + | torn := append(append([]byte(nil), data...), 9, 9, 9) | |
| 530 | + | ||
| 531 | + | rec, err := recoverDir(fsys.OS(), dir, nil, nil, nil) | |
| 532 | + | if err != nil { | |
| 533 | + | t.Fatal(err) | |
| 534 | + | } | |
| 535 | + | if _, err := recoverDir(fsys.OS(), dir, nil, nil, nil); !errors.Is(err, ErrLocked) { | |
| 536 | + | t.Fatalf("second recover: err = %v, want ErrLocked", err) | |
| 537 | + | } | |
| 538 | + | if _, err := rec.log.Write(data[logHeaderSize:]); err != nil { | |
| 539 | + | t.Fatal(err) | |
| 540 | + | } | |
| 541 | + | if _, err := rec.log.Write(torn[len(data):]); err != nil { | |
| 542 | + | t.Fatal(err) | |
| 543 | + | } | |
| 544 | + | if err := rec.log.Sync(); err != nil { | |
| 545 | + | t.Fatal(err) | |
| 546 | + | } | |
| 547 | + | _ = rec.log.Close() | |
| 548 | + | _ = rec.lock.Close() | |
| 549 | + | ||
| 550 | + | s := &state{m: map[string]string{}, versions: map[string]uint64{}} | |
| 551 | + | rec, err = recoverDir(fsys.OS(), dir, nil, s.load, s.apply) | |
| 552 | + | if err != nil { | |
| 553 | + | t.Fatal(err) | |
| 554 | + | } | |
| 555 | + | defer release(rec) | |
| 556 | + | wantState(t, s, map[string]string{"a": "1", "b": "2"}) | |
| 557 | + | if rec.lastSeq != 2 || rec.logSize != int64(len(data)) { | |
| 558 | + | t.Fatalf("rec = %+v", rec) | |
| 559 | + | } | |
| 560 | + | buf := make([]byte, len(data)+10) | |
| 561 | + | n, err := rec.log.ReadAt(buf, 0) | |
| 562 | + | if n != len(data) || (err != nil && !errors.Is(err, io.EOF)) { | |
| 563 | + | t.Fatalf("ReadAt = %d, %v: tail not truncated", n, err) | |
| 564 | + | } | |
| 565 | + | } | |
| @@ -0,0 +1,565 @@ | |||||
| 1 | + | // SPDX-License-Identifier: MIT | |||
| 2 | + | ||||
| 3 | + | package kv | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "log/slog" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "strings" | |||
| 13 | + | "testing" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite-kv/internal/fsys" | |||
| 16 | + | "bnema.dev/ermite-kv/internal/fsys/faultfs" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | const testDir = "/db" | |||
| 20 | + | ||||
| 21 | + | // state is the replayed content of a database. | |||
| 22 | + | type state struct { | |||
| 23 | + | m map[string]string | |||
| 24 | + | versions map[string]uint64 | |||
| 25 | + | seqs []uint64 // seqs passed to apply, in order | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (s *state) load(k, v []byte, ver uint64) { | |||
| 29 | + | s.m[string(k)] = string(v) | |||
| 30 | + | s.versions[string(k)] = ver | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (s *state) apply(seq uint64, ops []op) { | |||
| 34 | + | s.seqs = append(s.seqs, seq) | |||
| 35 | + | for _, o := range ops { | |||
| 36 | + | switch o.kind { | |||
| 37 | + | case opPut: | |||
| 38 | + | s.m[string(o.key)] = string(o.value) | |||
| 39 | + | s.versions[string(o.key)] = seq | |||
| 40 | + | case opDelete: | |||
| 41 | + | delete(s.m, string(o.key)) | |||
| 42 | + | delete(s.versions, string(o.key)) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func recoverState(t *testing.T, fx fsys.FS, logger *slog.Logger) (*state, recovered, error) { | |||
| 48 | + | t.Helper() | |||
| 49 | + | s := &state{m: map[string]string{}, versions: map[string]uint64{}} | |||
| 50 | + | rec, err := recoverDir(fx, testDir, logger, s.load, s.apply) | |||
| 51 | + | return s, rec, err | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // mustRecover recovers, checks the invariants of a successful recovery and | |||
| 55 | + | // releases the lock and the log file. | |||
| 56 | + | func mustRecover(t *testing.T, fx fsys.FS) (*state, recovered) { | |||
| 57 | + | t.Helper() | |||
| 58 | + | s, rec, err := recoverState(t, fx, nil) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatalf("recoverDir: %v", err) | |||
| 61 | + | } | |||
| 62 | + | size, err := rec.log.Size() | |||
| 63 | + | if err != nil || size != rec.logSize { | |||
| 64 | + | t.Fatalf("log size = %d (err %v), recovered.logSize = %d", size, err, rec.logSize) | |||
| 65 | + | } | |||
| 66 | + | t.Cleanup(func() { _ = rec.log.Close(); _ = rec.lock.Close() }) | |||
| 67 | + | return s, rec | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // release closes the log file and the lock of a recovery result. | |||
| 71 | + | func release(rec recovered) { | |||
| 72 | + | _ = rec.log.Close() | |||
| 73 | + | _ = rec.lock.Close() | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func putOp(k, v string) op { return op{kind: opPut, key: []byte(k), value: []byte(v)} } | |||
| 77 | + | func delOp(k string) op { return op{kind: opDelete, key: []byte(k)} } | |||
| 78 | + | ||||
| 79 | + | // logBytes builds a log file: header for base, then one record per batch with | |||
| 80 | + | // consecutive seqs starting at base+1. | |||
| 81 | + | func logBytes(base uint64, batches ...[]op) []byte { | |||
| 82 | + | b := appendLogHeader(nil, base) | |||
| 83 | + | for i, ops := range batches { | |||
| 84 | + | b = appendRecord(b, base+1+uint64(i), ops) | |||
| 85 | + | } | |||
| 86 | + | return b | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func put(fx *faultfs.FS, name string, data []byte) { fx.WriteFile(filepath.Join(testDir, name), data) } | |||
| 90 | + | ||||
| 91 | + | func snapshotBytes(t *testing.T, seq uint64, entries ...snapEntry) []byte { | |||
| 92 | + | t.Helper() | |||
| 93 | + | return encodeSnapshot(t, seq, entries) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func entry(k, v string, ver uint64) snapEntry { return snapEntry{[]byte(k), []byte(v), ver} } | |||
| 97 | + | ||||
| 98 | + | func wantState(t *testing.T, s *state, m map[string]string) { | |||
| 99 | + | t.Helper() | |||
| 100 | + | if len(s.m) != len(m) { | |||
| 101 | + | t.Fatalf("state = %v, want %v", s.m, m) | |||
| 102 | + | } | |||
| 103 | + | for k, v := range m { | |||
| 104 | + | if got, ok := s.m[k]; !ok || got != v { | |||
| 105 | + | t.Fatalf("state = %v, want %v", s.m, m) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestRecoverEmptyDirCreatesLog(t *testing.T) { | |||
| 111 | + | fx := faultfs.New() | |||
| 112 | + | s, rec := mustRecover(t, fx) | |||
| 113 | + | wantState(t, s, nil) | |||
| 114 | + | if rec.snapSeq != 0 || rec.lastSeq != 0 || rec.logBase != 0 || rec.logSize != logHeaderSize { | |||
| 115 | + | t.Fatalf("rec = %+v", rec) | |||
| 116 | + | } | |||
| 117 | + | // The new log must survive a power loss: file creation rules. | |||
| 118 | + | _ = rec.log.Close() | |||
| 119 | + | _ = rec.lock.Close() | |||
| 120 | + | fx.PowerLoss(faultfs.PowerLossOptions{}) | |||
| 121 | + | names, err := fx.ReadDir(testDir) | |||
| 122 | + | if err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | if got := strings.Join(names, ","); got != "LOCK,"+logName(0) && got != logName(0) { | |||
| 126 | + | t.Fatalf("after power loss: %v", names) | |||
| 127 | + | } | |||
| 128 | + | data, _ := fx.ReadFile(filepath.Join(testDir, logName(0))) | |||
| 129 | + | if !bytes.Equal(data, appendLogHeader(nil, 0)) { | |||
| 130 | + | t.Fatalf("log content %x", data) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestRecoverLockedAndRelease(t *testing.T) { | |||
| 135 | + | fx := faultfs.New() | |||
| 136 | + | _, rec := mustRecover(t, fx) | |||
| 137 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrLocked) { | |||
| 138 | + | t.Fatalf("second recover: err = %v, want ErrLocked", err) | |||
| 139 | + | } | |||
| 140 | + | _ = rec.log.Close() | |||
| 141 | + | _ = rec.lock.Close() | |||
| 142 | + | _, rec2, err := recoverState(t, fx, nil) | |||
| 143 | + | if err != nil { | |||
| 144 | + | t.Fatalf("recover after release: %v", err) | |||
| 145 | + | } | |||
| 146 | + | _ = rec2.log.Close() | |||
| 147 | + | _ = rec2.lock.Close() | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestRecoverReleasesLockOnError(t *testing.T) { | |||
| 151 | + | fx := faultfs.New() | |||
| 152 | + | put(fx, logName(0), []byte("garbage")) | |||
| 153 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |||
| 154 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |||
| 155 | + | } | |||
| 156 | + | l, err := fx.Lock(filepath.Join(testDir, lockName)) | |||
| 157 | + | if err != nil { | |||
| 158 | + | t.Fatalf("lock still held after failure: %v", err) | |||
| 159 | + | } | |||
| 160 | + | _ = l.Close() | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestRecoverCleanLog(t *testing.T) { | |||
| 164 | + | fx := faultfs.New() | |||
| 165 | + | data := logBytes(0, | |||
| 166 | + | []op{putOp("a", "1"), putOp("b", "2")}, | |||
| 167 | + | []op{delOp("a"), putOp("c", "3")}, | |||
| 168 | + | []op{putOp("b", "4")}, | |||
| 169 | + | ) | |||
| 170 | + | put(fx, logName(0), data) | |||
| 171 | + | s, rec := mustRecover(t, fx) | |||
| 172 | + | wantState(t, s, map[string]string{"b": "4", "c": "3"}) | |||
| 173 | + | if got := fmt.Sprint(s.seqs); got != "[1 2 3]" { | |||
| 174 | + | t.Fatalf("applied seqs %s", got) | |||
| 175 | + | } | |||
| 176 | + | if s.versions["b"] != 3 || s.versions["c"] != 2 { | |||
| 177 | + | t.Fatalf("versions = %v", s.versions) | |||
| 178 | + | } | |||
| 179 | + | if rec.snapSeq != 0 || rec.lastSeq != 3 || rec.logBase != 0 || rec.logSize != int64(len(data)) { | |||
| 180 | + | t.Fatalf("rec = %+v", rec) | |||
| 181 | + | } | |||
| 182 | + | // Appends go to the end of the recovered log. | |||
| 183 | + | if _, err := rec.log.Write([]byte("x")); err != nil { | |||
| 184 | + | t.Fatal(err) | |||
| 185 | + | } | |||
| 186 | + | if size, _ := rec.log.Size(); size != int64(len(data))+1 { | |||
| 187 | + | t.Fatalf("size after append = %d", size) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestRecoverDeletesTmpFiles(t *testing.T) { | |||
| 192 | + | fx := faultfs.New() | |||
| 193 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |||
| 194 | + | put(fx, snapshotName(5)+tmpSuffix, []byte("partial")) | |||
| 195 | + | put(fx, logName(5)+tmpSuffix, []byte("partial")) | |||
| 196 | + | s, _ := mustRecover(t, fx) | |||
| 197 | + | wantState(t, s, map[string]string{"a": "1"}) | |||
| 198 | + | names, _ := fx.ReadDir(testDir) | |||
| 199 | + | for _, n := range names { | |||
| 200 | + | if strings.HasSuffix(n, tmpSuffix) { | |||
| 201 | + | t.Fatalf("tmp file %s left", n) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | // Deletion is durable. | |||
| 205 | + | fx.PowerLoss(faultfs.PowerLossOptions{}) | |||
| 206 | + | names, _ = fx.ReadDir(testDir) | |||
| 207 | + | for _, n := range names { | |||
| 208 | + | if strings.HasSuffix(n, tmpSuffix) { | |||
| 209 | + | t.Fatalf("tmp file %s back after power loss", n) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestRecoverTornTail(t *testing.T) { | |||
| 215 | + | good := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")}) | |||
| 216 | + | next := appendRecord(nil, 3, []op{putOp("c", "3")}) | |||
| 217 | + | cases := map[string][]byte{ | |||
| 218 | + | "short frame": next[:3], | |||
| 219 | + | "short record": next[:len(next)-2], | |||
| 220 | + | "zero-filled tail": make([]byte, 100), | |||
| 221 | + | "garbage": []byte("this is not a record at all, just junk bytes"), | |||
| 222 | + | "bad crc": append(append([]byte(nil), next[:len(next)-1]...), next[len(next)-1]^1), | |||
| 223 | + | "huge length": {0xff, 0xff, 0xff, 0x7f, 1, 2, 3, 4, 5, 6, 7, 8}, | |||
| 224 | + | "one byte": {0x01}, | |||
| 225 | + | } | |||
| 226 | + | for name, tail := range cases { | |||
| 227 | + | t.Run(name, func(t *testing.T) { | |||
| 228 | + | fx := faultfs.New() | |||
| 229 | + | put(fx, logName(0), append(append([]byte(nil), good...), tail...)) | |||
| 230 | + | var logs bytes.Buffer | |||
| 231 | + | s, rec, err := recoverState(t, fx, slog.New(slog.NewTextHandler(&logs, nil))) | |||
| 232 | + | if err != nil { | |||
| 233 | + | t.Fatal(err) | |||
| 234 | + | } | |||
| 235 | + | defer release(rec) | |||
| 236 | + | wantState(t, s, map[string]string{"a": "1", "b": "2"}) | |||
| 237 | + | if rec.lastSeq != 2 || rec.logSize != int64(len(good)) { | |||
| 238 | + | t.Fatalf("rec = %+v, want size %d", rec, len(good)) | |||
| 239 | + | } | |||
| 240 | + | if got, _ := fx.ReadFile(filepath.Join(testDir, logName(0))); !bytes.Equal(got, good) { | |||
| 241 | + | t.Fatalf("log not truncated: %d bytes, want %d", len(got), len(good)) | |||
| 242 | + | } | |||
| 243 | + | if got, _ := fx.DurableFile(filepath.Join(testDir, logName(0))); !bytes.Equal(got, good) { | |||
| 244 | + | t.Fatal("truncation not synced") | |||
| 245 | + | } | |||
| 246 | + | if !strings.Contains(logs.String(), "dropped torn log tail") { | |||
| 247 | + | t.Fatalf("no warning logged: %q", logs.String()) | |||
| 248 | + | } | |||
| 249 | + | }) | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func TestRecoverTornTailOnlyHeaderSurvives(t *testing.T) { | |||
| 254 | + | fx := faultfs.New() | |||
| 255 | + | put(fx, logName(0), append(logBytes(0), make([]byte, 7)...)) | |||
| 256 | + | s, rec := mustRecover(t, fx) | |||
| 257 | + | wantState(t, s, nil) | |||
| 258 | + | if rec.logSize != logHeaderSize { | |||
| 259 | + | t.Fatalf("logSize = %d", rec.logSize) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func TestRecoverDamageFollowedByValidRecord(t *testing.T) { | |||
| 264 | + | r1 := appendRecord(nil, 1, []op{putOp("a", "1")}) | |||
| 265 | + | r2 := appendRecord(nil, 2, []op{putOp("b", "2")}) | |||
| 266 | + | r3 := appendRecord(nil, 3, []op{putOp("c", "3")}) | |||
| 267 | + | flip := func(b []byte, i int) []byte { | |||
| 268 | + | c := append([]byte(nil), b...) | |||
| 269 | + | c[i] ^= 0xff | |||
| 270 | + | return c | |||
| 271 | + | } | |||
| 272 | + | hdr := appendLogHeader(nil, 0) | |||
| 273 | + | cat := func(parts ...[]byte) []byte { return bytes.Join(parts, nil) } | |||
| 274 | + | cases := map[string][]byte{ | |||
| 275 | + | "bad crc in middle record": cat(hdr, r1, flip(r2, len(r2)-1), r3), | |||
| 276 | + | "bad payload in first record": cat(hdr, flip(r1, 9), r2), | |||
| 277 | + | "bad checksum field": cat(hdr, r1, flip(r2, 4), r3), | |||
| 278 | + | "damaged record before last": cat(hdr, flip(r1, len(r1)-1), r2, r3), | |||
| 279 | + | "record with wrong count": cat(hdr, rawRecord(rawPayload(1, 5, opDelete, 1, 'k')), r2), | |||
| 280 | + | "corrupt but len looks intact": cat(hdr, r1, flip(r2, 12), r3), | |||
| 281 | + | } | |||
| 282 | + | for name, data := range cases { | |||
| 283 | + | t.Run(name, func(t *testing.T) { | |||
| 284 | + | fx := faultfs.New() | |||
| 285 | + | put(fx, logName(0), data) | |||
| 286 | + | _, _, err := recoverState(t, fx, nil) | |||
| 287 | + | if !errors.Is(err, ErrCorrupt) { | |||
| 288 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |||
| 289 | + | } | |||
| 290 | + | // The file must be left untouched. | |||
| 291 | + | if got, _ := fx.ReadFile(filepath.Join(testDir, logName(0))); !bytes.Equal(got, data) { | |||
| 292 | + | t.Fatal("corrupt log was modified") | |||
| 293 | + | } | |||
| 294 | + | }) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestRecoverDamageInOlderLog(t *testing.T) { | |||
| 299 | + | old := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")}) | |||
| 300 | + | cases := map[string][]byte{ | |||
| 301 | + | "torn tail": old[:len(old)-3], | |||
| 302 | + | "bad crc": append(append([]byte(nil), old[:len(old)-1]...), old[len(old)-1]^1), | |||
| 303 | + | "zero tail": append(append([]byte(nil), old...), make([]byte, 30)...), | |||
| 304 | + | "extra bytes": append(append([]byte(nil), old...), 1, 2, 3), | |||
| 305 | + | } | |||
| 306 | + | for name, data := range cases { | |||
| 307 | + | t.Run(name, func(t *testing.T) { | |||
| 308 | + | fx := faultfs.New() | |||
| 309 | + | put(fx, logName(0), data) | |||
| 310 | + | put(fx, logName(2), logBytes(2, []op{putOp("c", "3")})) | |||
| 311 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |||
| 312 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |||
| 313 | + | } | |||
| 314 | + | }) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func TestRecoverBadLogHeader(t *testing.T) { | |||
| 319 | + | good := logBytes(0, []op{putOp("a", "1")}) | |||
| 320 | + | flip := func(i int) []byte { | |||
| 321 | + | c := append([]byte(nil), good...) | |||
| 322 | + | c[i] ^= 1 | |||
| 323 | + | return c | |||
| 324 | + | } | |||
| 325 | + | cases := map[string]struct { | |||
| 326 | + | name string | |||
| 327 | + | data []byte | |||
| 328 | + | }{ | |||
| 329 | + | "empty file": {logName(0), nil}, | |||
| 330 | + | "short header": {logName(0), good[:10]}, | |||
| 331 | + | "bad magic": {logName(0), flip(0)}, | |||
| 332 | + | "bad crc": {logName(0), flip(18)}, | |||
| 333 | + | "flipped base": {logName(0), flip(9)}, | |||
| 334 | + | "base != name": {logName(7), good}, | |||
| 335 | + | "all zero header": {logName(0), make([]byte, logHeaderSize)}, | |||
| 336 | + | } | |||
| 337 | + | for name, c := range cases { | |||
| 338 | + | t.Run(name, func(t *testing.T) { | |||
| 339 | + | fx := faultfs.New() | |||
| 340 | + | put(fx, c.name, c.data) | |||
| 341 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |||
| 342 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |||
| 343 | + | } | |||
| 344 | + | }) | |||
| 345 | + | } | |||
| 346 | + | t.Run("bad header in older log", func(t *testing.T) { | |||
| 347 | + | fx := faultfs.New() | |||
| 348 | + | put(fx, logName(0), good[:10]) | |||
| 349 | + | put(fx, logName(1), logBytes(1, []op{putOp("b", "2")})) | |||
| 350 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |||
| 351 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |||
| 352 | + | } | |||
| 353 | + | }) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestRecoverSeqGap(t *testing.T) { | |||
| 357 | + | cases := map[string]func(fx *faultfs.FS){ | |||
| 358 | + | "gap inside a log": func(fx *faultfs.FS) { | |||
| 359 | + | b := appendLogHeader(nil, 0) | |||
| 360 | + | b = appendRecord(b, 1, []op{putOp("a", "1")}) | |||
| 361 | + | b = appendRecord(b, 3, []op{putOp("b", "2")}) | |||
| 362 | + | put(fx, logName(0), b) | |||
| 363 | + | }, | |||
| 364 | + | "first record not base+1": func(fx *faultfs.FS) { | |||
| 365 | + | b := appendRecord(appendLogHeader(nil, 0), 2, []op{putOp("a", "1")}) | |||
| 366 | + | put(fx, logName(0), b) | |||
| 367 | + | }, | |||
| 368 | + | "gap between logs": func(fx *faultfs.FS) { | |||
| 369 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |||
| 370 | + | put(fx, logName(2), logBytes(2, []op{putOp("c", "3")})) | |||
| 371 | + | }, | |||
| 372 | + | "first log starts after seq 0": func(fx *faultfs.FS) { | |||
| 373 | + | put(fx, logName(4), logBytes(4, []op{putOp("a", "1")})) | |||
| 374 | + | }, | |||
| 375 | + | "log after snapshot has a gap": func(fx *faultfs.FS) { | |||
| 376 | + | put(fx, snapshotName(3), snapshotBytes(t, 3, entry("a", "1", 3))) | |||
| 377 | + | put(fx, logName(5), logBytes(5, []op{putOp("b", "2")})) | |||
| 378 | + | }, | |||
| 379 | + | "logs end before snapshot": func(fx *faultfs.FS) { | |||
| 380 | + | put(fx, snapshotName(5), snapshotBytes(t, 5, entry("a", "1", 3))) | |||
| 381 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |||
| 382 | + | }, | |||
| 383 | + | "duplicate seq": func(fx *faultfs.FS) { | |||
| 384 | + | b := appendLogHeader(nil, 0) | |||
| 385 | + | b = appendRecord(b, 1, []op{putOp("a", "1")}) | |||
| 386 | + | b = appendRecord(b, 1, []op{putOp("b", "2")}) | |||
| 387 | + | put(fx, logName(0), b) | |||
| 388 | + | }, | |||
| 389 | + | } | |||
| 390 | + | for name, setup := range cases { | |||
| 391 | + | t.Run(name, func(t *testing.T) { | |||
| 392 | + | fx := faultfs.New() | |||
| 393 | + | setup(fx) | |||
| 394 | + | if _, _, err := recoverState(t, fx, nil); !errors.Is(err, ErrCorrupt) { | |||
| 395 | + | t.Fatalf("err = %v, want ErrCorrupt", err) | |||
| 396 | + | } | |||
| 397 | + | }) | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | func TestRecoverSnapshotThenLogs(t *testing.T) { | |||
| 402 | + | fx := faultfs.New() | |||
| 403 | + | put(fx, snapshotName(3), snapshotBytes(t, 3, entry("a", "1", 1), entry("b", "2", 3))) | |||
| 404 | + | // log-0 still holds records already in the snapshot; log-3 continues it. | |||
| 405 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "old")}, []op{putOp("x", "gone")}, []op{putOp("b", "2")})) | |||
| 406 | + | put(fx, logName(3), logBytes(3, []op{putOp("c", "3")}, []op{delOp("a")})) | |||
| 407 | + | s, rec := mustRecover(t, fx) | |||
| 408 | + | wantState(t, s, map[string]string{"b": "2", "c": "3"}) | |||
| 409 | + | if got := fmt.Sprint(s.seqs); got != "[4 5]" { | |||
| 410 | + | t.Fatalf("applied seqs %s", got) | |||
| 411 | + | } | |||
| 412 | + | if s.versions["b"] != 3 || s.versions["c"] != 4 { | |||
| 413 | + | t.Fatalf("versions = %v", s.versions) | |||
| 414 | + | } | |||
| 415 | + | if rec.snapSeq != 3 || rec.lastSeq != 5 || rec.logBase != 3 { | |||
| 416 | + | t.Fatalf("rec = %+v", rec) | |||
| 417 | + | } | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | func TestRecoverSnapshotWithoutLogCreatesLog(t *testing.T) { | |||
| 421 | + | fx := faultfs.New() | |||
| 422 | + | put(fx, snapshotName(7), snapshotBytes(t, 7, entry("a", "1", 7))) | |||
| 423 | + | s, rec := mustRecover(t, fx) | |||
| 424 | + | wantState(t, s, map[string]string{"a": "1"}) | |||
| 425 | + | if rec.snapSeq != 7 || rec.lastSeq != 7 || rec.logBase != 7 || rec.logSize != logHeaderSize { | |||
| 426 | + | t.Fatalf("rec = %+v", rec) | |||
| 427 | + | } | |||
| 428 | + | if _, ok := fx.ReadFile(filepath.Join(testDir, logName(7))); !ok { | |||
| 429 | + | t.Fatal("log-7 not created") | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | func TestRecoverInvalidNewestSnapshotFallsBack(t *testing.T) { | |||
| 434 | + | good := snapshotBytes(t, 2, entry("a", "1", 1), entry("b", "2", 2)) | |||
| 435 | + | newer := snapshotBytes(t, 4, entry("a", "1", 1), entry("z", "9", 4)) | |||
| 436 | + | truncated := newer[:len(newer)-2] | |||
| 437 | + | flipped := append([]byte(nil), newer...) | |||
| 438 | + | flipped[30] ^= 1 | |||
| 439 | + | wrongName := snapshotBytes(t, 3, entry("a", "1", 1)) | |||
| 440 | + | for name, bad := range map[string][]byte{"truncated": truncated, "flipped": flipped, "empty": nil, "seq/name mismatch": wrongName} { | |||
| 441 | + | t.Run(name, func(t *testing.T) { | |||
| 442 | + | fx := faultfs.New() | |||
| 443 | + | put(fx, snapshotName(2), good) | |||
| 444 | + | put(fx, snapshotName(4), bad) | |||
| 445 | + | put(fx, logName(2), logBytes(2, []op{putOp("c", "3")}, []op{putOp("d", "4")}, []op{delOp("a")})) | |||
| 446 | + | var logs bytes.Buffer | |||
| 447 | + | s, rec, err := recoverState(t, fx, slog.New(slog.NewTextHandler(&logs, nil))) | |||
| 448 | + | if err != nil { | |||
| 449 | + | t.Fatal(err) | |||
| 450 | + | } | |||
| 451 | + | defer release(rec) | |||
| 452 | + | wantState(t, s, map[string]string{"b": "2", "c": "3", "d": "4"}) | |||
| 453 | + | if rec.snapSeq != 2 || rec.lastSeq != 5 { | |||
| 454 | + | t.Fatalf("rec = %+v", rec) | |||
| 455 | + | } | |||
| 456 | + | if !strings.Contains(logs.String(), "invalid snapshot") { | |||
| 457 | + | t.Fatalf("fallback not logged: %q", logs.String()) | |||
| 458 | + | } | |||
| 459 | + | }) | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | func TestRecoverInvalidSnapshotNoOtherFallsBackToEmpty(t *testing.T) { | |||
| 464 | + | fx := faultfs.New() | |||
| 465 | + | put(fx, snapshotName(3), []byte("junk")) | |||
| 466 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |||
| 467 | + | s, rec := mustRecover(t, fx) | |||
| 468 | + | wantState(t, s, map[string]string{"a": "1"}) | |||
| 469 | + | if rec.snapSeq != 0 || rec.lastSeq != 1 { | |||
| 470 | + | t.Fatalf("rec = %+v", rec) | |||
| 471 | + | } | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | func TestRecoverNoSnapshotStartsAtZero(t *testing.T) { | |||
| 475 | + | fx := faultfs.New() | |||
| 476 | + | put(fx, logName(0), logBytes(0, []op{putOp("a", "1")})) | |||
| 477 | + | put(fx, logName(1), logBytes(1, []op{putOp("b", "2")})) | |||
| 478 | + | put(fx, "unrelated.txt", []byte("ignored")) | |||
| 479 | + | put(fx, "log-xyz", []byte("ignored")) | |||
| 480 | + | s, rec := mustRecover(t, fx) | |||
| 481 | + | wantState(t, s, map[string]string{"a": "1", "b": "2"}) | |||
| 482 | + | if rec.snapSeq != 0 || rec.lastSeq != 2 || rec.logBase != 1 { | |||
| 483 | + | t.Fatalf("rec = %+v", rec) | |||
| 484 | + | } | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func TestRecoverSnapshotLoadOrderAndOwnership(t *testing.T) { | |||
| 488 | + | fx := faultfs.New() | |||
| 489 | + | put(fx, snapshotName(1), snapshotBytes(t, 1, entry("a", "1", 1), entry("b", "2", 1), entry("c", "3", 1))) | |||
| 490 | + | var keys []string | |||
| 491 | + | var held [][]byte | |||
| 492 | + | _, err := recoverDir(fx, testDir, nil, func(k, v []byte, ver uint64) { | |||
| 493 | + | keys = append(keys, string(k)) | |||
| 494 | + | held = append(held, k, v) | |||
| 495 | + | }, nil) | |||
| 496 | + | if err != nil { | |||
| 497 | + | t.Fatal(err) | |||
| 498 | + | } | |||
| 499 | + | if strings.Join(keys, "") != "abc" { | |||
| 500 | + | t.Fatalf("keys = %v", keys) | |||
| 501 | + | } | |||
| 502 | + | var all []byte | |||
| 503 | + | for _, h := range held { | |||
| 504 | + | all = append(all, h...) | |||
| 505 | + | } | |||
| 506 | + | if string(all) != "a1b2c3" { | |||
| 507 | + | t.Fatalf("retained slices changed: %q", all) | |||
| 508 | + | } | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | func TestRecoverPowerLossAfterRecovery(t *testing.T) { | |||
| 512 | + | // A fresh directory survives a power loss right after recovery and | |||
| 513 | + | // reopens cleanly with the same state. | |||
| 514 | + | fx := faultfs.New() | |||
| 515 | + | _, rec := mustRecover(t, fx) | |||
| 516 | + | _ = rec.log.Close() | |||
| 517 | + | _ = rec.lock.Close() | |||
| 518 | + | fx.PowerLoss(faultfs.PowerLossOptions{}) | |||
| 519 | + | s, rec2 := mustRecover(t, fx) | |||
| 520 | + | wantState(t, s, nil) | |||
| 521 | + | if rec2.logBase != 0 { | |||
| 522 | + | t.Fatalf("rec = %+v", rec2) | |||
| 523 | + | } | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | func TestRecoverOSFilesystem(t *testing.T) { | |||
| 527 | + | dir := filepath.Join(t.TempDir(), "nested", "db") | |||
| 528 | + | data := logBytes(0, []op{putOp("a", "1")}, []op{putOp("b", "2")}) | |||
| 529 | + | torn := append(append([]byte(nil), data...), 9, 9, 9) | |||
| 530 | + | ||||
| 531 | + | rec, err := recoverDir(fsys.OS(), dir, nil, nil, nil) | |||
| 532 | + | if err != nil { | |||
| 533 | + | t.Fatal(err) | |||
| 534 | + | } | |||
| 535 | + | if _, err := recoverDir(fsys.OS(), dir, nil, nil, nil); !errors.Is(err, ErrLocked) { | |||
| 536 | + | t.Fatalf("second recover: err = %v, want ErrLocked", err) | |||
| 537 | + | } | |||
| 538 | + | if _, err := rec.log.Write(data[logHeaderSize:]); err != nil { | |||
| 539 | + | t.Fatal(err) | |||
| 540 | + | } | |||
| 541 | + | if _, err := rec.log.Write(torn[len(data):]); err != nil { | |||
| 542 | + | t.Fatal(err) | |||
| 543 | + | } | |||
| 544 | + | if err := rec.log.Sync(); err != nil { | |||
| 545 | + | t.Fatal(err) | |||
| 546 | + | } | |||
| 547 | + | _ = rec.log.Close() | |||
| 548 | + | _ = rec.lock.Close() | |||
| 549 | + | ||||
| 550 | + | s := &state{m: map[string]string{}, versions: map[string]uint64{}} | |||
| 551 | + | rec, err = recoverDir(fsys.OS(), dir, nil, s.load, s.apply) | |||
| 552 | + | if err != nil { | |||
| 553 | + | t.Fatal(err) | |||
| 554 | + | } | |||
| 555 | + | defer release(rec) | |||
| 556 | + | wantState(t, s, map[string]string{"a": "1", "b": "2"}) | |||
| 557 | + | if rec.lastSeq != 2 || rec.logSize != int64(len(data)) { | |||
| 558 | + | t.Fatalf("rec = %+v", rec) | |||
| 559 | + | } | |||
| 560 | + | buf := make([]byte, len(data)+10) | |||
| 561 | + | n, err := rec.log.ReadAt(buf, 0) | |||
| 562 | + | if n != len(data) || (err != nil && !errors.Is(err, io.EOF)) { | |||
| 563 | + | t.Fatalf("ReadAt = %d, %v: tail not truncated", n, err) | |||
| 564 | + | } | |||
| 565 | + | } | |||