Fix SaveC1Z data loss race: use atomic rename instead of truncating write#695
Fix SaveC1Z data loss race: use atomic rename instead of truncating write#695
Conversation
…rite SaveC1Z previously used os.Create() which truncates the destination file immediately before copying data. If the process is killed mid-copy, the file is left with a valid c1z header but a truncated zstd stream, causing all subsequent sync attempts to fail until manual intervention. Replace with write-to-staging → fsync → atomic rename pattern (matching the internal saveC1z function). The destination file is either fully written or unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
WalkthroughThe Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
pkg/dotc1z/manager/local/local.go (2)
186-193: Consider adding size verification after copy for consistency.The
copyFileToTmpfunction (lines 82-91) verifies that the copied size matches the source file size. For consistency and defense-in-depth, consider adding similar verification here after the copy and sync operations.♻️ Optional: Add size verification
size, err := io.Copy(dstFile, tmpFile) if err != nil { return fmt.Errorf("failed to copy to staging file: %w", err) } if err := dstFile.Sync(); err != nil { return fmt.Errorf("failed to sync staging file: %w", err) } + + // Verify size matches source + srcStat, err := tmpFile.Stat() + if err != nil { + return fmt.Errorf("failed to stat source temp file: %w", err) + } + if size != srcStat.Size() { + return fmt.Errorf("copy size mismatch: expected %d bytes but copied %d bytes", srcStat.Size(), size) + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pkg/dotc1z/manager/local/local.go` around lines 186 - 193, After copying to the staging file with io.Copy(dstFile, tmpFile) in the same function, verify that the returned size equals the expected source size (same check done in copyFileToTmp); after the Sync() call compare size to the source file's size variable (or stat the original) and return an error like "copied size mismatch" if they differ; update the block around dstFile, tmpFile and io.Copy to perform this size validation for consistency and defense-in-depth.
170-171: Consider usingos.CreateTempfor the staging file to avoid collisions.The staging path
l.filePath + ".tmp"is predictable. If multiple processes or goroutines attempt to save to the same destination concurrently, they could overwrite each other's staging files. Usingos.CreateTempin the same directory would generate a unique name and avoid potential races.That said, if concurrent saves to the same destination are not a supported use case, this is acceptable.
♻️ Optional: Use unique staging file
- stagingPath := l.filePath + ".tmp" - dstFile, err := os.OpenFile(stagingPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + dstFile, err := os.CreateTemp(filepath.Dir(l.filePath), ".tmp-*") if err != nil { return fmt.Errorf("failed to create staging file: %w", err) } + stagingPath := dstFile.Name()Note: This would require adding
"path/filepath"to imports.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@pkg/dotc1z/manager/local/local.go` around lines 170 - 171, The staging file name is predictable (stagingPath := l.filePath + ".tmp") which risks collisions; change the logic in the save/write routine that uses stagingPath and dstFile (referencing l.filePath and dstFile) to create a unique temp file in the same directory via os.CreateTemp (importing path/filepath), write to and close that temp file, set the desired permissions if needed, then atomically replace the target with os.Rename to l.filePath; this ensures uniqueness and avoids concurrent overwrite races.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@pkg/dotc1z/manager/local/local.go`:
- Around line 186-193: After copying to the staging file with io.Copy(dstFile,
tmpFile) in the same function, verify that the returned size equals the expected
source size (same check done in copyFileToTmp); after the Sync() call compare
size to the source file's size variable (or stat the original) and return an
error like "copied size mismatch" if they differ; update the block around
dstFile, tmpFile and io.Copy to perform this size validation for consistency and
defense-in-depth.
- Around line 170-171: The staging file name is predictable (stagingPath :=
l.filePath + ".tmp") which risks collisions; change the logic in the save/write
routine that uses stagingPath and dstFile (referencing l.filePath and dstFile)
to create a unique temp file in the same directory via os.CreateTemp (importing
path/filepath), write to and close that temp file, set the desired permissions
if needed, then atomically replace the target with os.Rename to l.filePath; this
ensures uniqueness and avoids concurrent overwrite races.
|
Closing in favor of #606 which covers the same atomic-write fix for local.go plus also fixes file.go and adds tests. |
Pull request was closed
Summary
localManager.SaveC1Z()usedos.Create()which truncates the destination file immediately before writing datasaveC1z()function infile.goTest plan
go build ./pkg/dotc1z/manager/local/...passesos.Remove🤖 Generated with Claude Code
Summary by CodeRabbit