Upstream: not filed. The GitHub repository is no longer public, so there is nowhere to report it; v0.2.0 is still served by proxy.golang.org.
Repo: jmsadair/raft (tested on v0.2.0, the latest release, commit 8dd4cdc; the GitHub repository is no longer public, the module is still served by proxy.golang.org)
Severity: low. One ordinary crash (kill -9, OOM kill, power loss) during a snapshot makes the next start fail with a misleading error. A second start succeeds. No torn writes or strict-POSIX assumptions needed; reproduced on a real filesystem.
Summary
Snapshots are written into a temporary directory, snapshots/tmp-snapshotNNN/ holding snapshot.bin and metadata.json, which is renamed to snapshot-<timestamp> when complete (snapshot_storage.go:191-231 and :99-140). If the process dies before the rename, the temporary directory stays behind. NewSnapshotStorage cleans it up on the next start with fileutil.RemoveTmpFiles (snapshot_storage.go:183):
// internal/fileutil/fileutil.go:11-21
func RemoveTmpFiles(rootDir string) error {
return filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !strings.HasPrefix(info.Name(), "tmp") {
return nil
}
return os.RemoveAll(path)
})
}
filepath.Walk reads a directory's names before calling the walk function on it. The callback removes tmp-snapshotNNN with everything inside it and returns nil, so Walk goes on to lstat the children it already listed. That fails with "no such file or directory", Walk passes the error to the callback, and the callback returns it:
could not remove temporary files: lstat data/snapshots/tmp-snapshot1196913385/metadata.json: no such file or directory
By then the directory has been deleted, but NewRaft has already failed. A second start succeeds, so a supervisor that restarts the process on failure recovers. A process that is started once (an operator script, or a deployment that treats a failed start as fatal) stays down, and the error points at a file that no longer exists.
The same helper is used for log/ (log.go:191) and state/ (state_storage.go:102), but those only ever contain temporary files, never directories, so only the snapshot directory triggers it.
Reproduction
On a real filesystem:
dir, _ := os.MkdirTemp("", "jms")
tmp := filepath.Join(dir, "snapshots", "tmp-snapshot42")
os.MkdirAll(tmp, 0o755)
os.WriteFile(filepath.Join(tmp, "snapshot.bin"), []byte("x"), 0o644)
os.WriteFile(filepath.Join(tmp, "metadata.json"), []byte("{}"), 0o644)
_, err := raft.NewSnapshotStorage(dir)
// could not remove temporary files: lstat .../snapshots/tmp-snapshot42/metadata.json: no such file or directory
With seedfall, on a 3-node key-value store that snapshots every 32 entries:
cd examples/jmsraft
seedfall replay -c seedfall.json -seed 16 -faults tmpdir.faults.json
One crash, while n3 is writing a snapshot:
0.898s fault crash n3#1 during disk op 170, restart at 1.286s
1.286s start n3#2
1.286s FAIL n3#2: node restarts from its own disk: could not remove temporary files: lstat data/snapshots/tmp-snapshot2799775943/metadata.json: No such file or directory
Suggested fix
Skip a directory after removing it:
if err := os.RemoveAll(path); err != nil {
return err
}
if info.IsDir() {
return filepath.SkipDir
}
return nil
Fix verified: with this change applied to a local copy of v0.2.0 (together with the state file and torn log tail fixes), seedfall.json passes 150 seeds of crashes, partitions, torn writes and crashes during disk I/O, linearizable, and the reproduction above succeeds.