Conversation
…aves - Rename the original to .bak instead of deleting it, and delete the backup only after the temp file is renamed. Check each rename() result and roll back on failure. On startup, restore a missing .zpln from .tmp if it parses as a note, otherwise from .bak.
- For file:// paths, use Files.move with ATOMIC_MOVE and REPLACE_EXISTING instead of FileObject.moveTo, which deletes the destination first. Keep moveTo for other file systems and as a fallback.
| if (path.getName().endsWith(targetSuffix + suffix)) { | ||
| String pathString = path.toString(); | ||
| Path file = new Path(pathString.substring(0, pathString.length() - suffix.length())); | ||
| if (!fs.exists(file)) { |
There was a problem hiding this comment.
Keeping the original until the new file is in place looks like the right direction to me. I ran into one case, though, where this check treats a note that was already deleted or moved as an interrupted save and brings it back.
What happens
recoverInterruptedWrites restores a .zpln whenever it is missing and a complete .zpln.tmp exists. But FileSystemNotebookRepo.remove() and move() only delete/rename the .zpln itself, so a .tmp (or .bak) left next to it stays behind. On the next restart, that leftover is treated as an interrupted save:
- note removed → it comes back after restart
- note moved → a second
.zplnwith the same noteId appears at the old path;list()keys by noteId, so only one of the two is listed
A complete .tmp can be left next to an existing .zpln in two ways with this PR:
- Zeppelin stops after the
.tmpis fully written but beforefileis renamed to.bak. rename(tmpFile, file)fails:replaceFilerestores the original from.bakand throws, but does not delete.tmp. This one does not need a crash.
I checked (2) end to end by making rename(*.zpln.tmp → *.zpln) fail once: the save throws, the original .zpln and a complete .tmp remain, and after remove() + restart the note comes back, with the content of the save that had failed.
Repro
These two tests fail on the PR branch when added to FileSystemNotebookRepoTest:
@Test
void testRemovedNoteIsNotRestoredFromLeftoverTmp() throws IOException {
Note note = createNote("/title_1", "value_1");
hdfsNotebookRepo.save(note, authInfo);
writeString(noteFile(note, ".tmp"), note.toJson());
hdfsNotebookRepo.remove(note.getId(), note.getPath(), authInfo);
restartRepo();
assertEquals(0, hdfsNotebookRepo.list(authInfo).size()); // actual: 1
}
@Test
void testMovedNoteIsNotDuplicatedFromLeftoverTmp() throws IOException {
Note note = createNote("/title_1", "value_1");
hdfsNotebookRepo.save(note, authInfo);
writeString(noteFile(note, ".tmp"), note.toJson());
hdfsNotebookRepo.move(note.getId(), "/title_1", "/dir/title_2", authInfo);
restartRepo();
long zplnCount;
try (Stream<java.nio.file.Path> files = Files.walk(Paths.get(notebookDir))) {
zplnCount = files.filter(f -> f.toString().endsWith(".zpln")).count();
}
assertEquals(1, zplnCount); // actual: 2
}Possible fixes (just ideas, happy to go with whatever you prefer)
- In
remove()/move(), also delete (or move along) the sibling.tmp/.bak. - In
replaceFile, deletetmpFilewhen the final rename fails and the original has been restored. - Only auto-recover when both
.bakand.tmpexist without the.zpln, which is exactly the state this PR'swriteFileleaves when it is interrupted between the two renames. For the.tmp-only case (notes lost by earlier versions), a complete.tmpcan't be told apart from a leftover of a deleted note, so logging it or moving it aside for manual recovery might be safer than restoring it automatically.
There was a problem hiding this comment.
Thanks for the careful review and the repro tests, that was a real gap.
I went with options 2 and 3 in comment:
replaceFilenow deletes.tmpwhen the final rename fails and the original has been restored.- Recovery only runs when both
.bakand.tmpexist without the.zpln, which is the statewriteFileleaves when it stops between its two renames. A single leftover.tmpor.bakis logged instead of restored.
I also added your two tests, and they pass now. As you pointed out, this means notes lost by earlier versions (only .tmp left) are no longer restored automatically, so I updated the PR description accordingly.
I didn't change remove() / move() for option 1, to keep this PR focused on the save path. Happy to add it here or in a follow-up if you think it's needed.
voidmatcha
left a comment
There was a problem hiding this comment.
The correctness issue raised earlier appears to be resolved in the latest commit. The code looks good to me. 👍
One small documentation mismatch remains: a lone .tmp file is no longer recovered automatically, so please update the PR description and the recoverInterruptedSaves() Javadoc to match the current behavior.
|
Thanks for the review! I updated the recoverInterruptedSaves() Javadoc in the latest commit and the PR description to match the current behavior. |
What is this PR for?
A note can disappear after a restart because saving it is not atomic.
FileSystemStorage.writeFilewritesnote.zpln.tmp, deletesnote.zpln, then renames the temp file. If Zeppelin stops between the delete and the rename, only the.tmpfile is left.list()picks up.zplnfiles only, so the note looks lost.rename()also returnsfalseinstead of throwing, and the result was ignored.VFSNotebookRepo.savehas the same problem, becauseFileObject.moveTodeletes the destination before renaming.This PR:
FileSystemStorage.writeFile: renames the original to.bakinstead of deleting it, and deletes the backup only after the temp file is renamed. Eachrename()result is checked, and the original is restored from the backup if the final rename fails.FileSystemConfigStorageandFileSystemRecoveryStorageuse the same method, so their files get the same protection.FileSystemNotebookRepo.init: restores notes left by an interrupted save, before they are listed. A .zpln file is restored only when it is missing and both its .bak and .tmp files exist:A
.tmpfile counts as complete when it parses as a note, since it may be cut off if the write itself was interrupted.A single leftover file is not restored, because it can't be told apart from a leftover of a removed or moved note.
VFSNotebookRepo.save: for localfile://paths, replaces the note withFiles.move(ATOMIC_MOVE, REPLACE_EXISTING), so the note file is never missing. The affectedFileObjects are refreshed, because the move bypasses the VFS cache. Other file systems keep usingmoveTo, and so do local file systems that do not support atomic moves.On the question in the issue about dropping VFS2 for local files: this PR keeps VFS2 and uses NIO only for this one move, to keep the change small.
What type of PR is it?
Bug Fix
Todos
VFSNotebookRepoWhat is the Jira issue?
ZEPPELIN-5718
How should this be tested?
FileSystemNotebookRepoTest: a normal save leaves no.tmpor.bakfile. The other tests create the files a crash leaves behind (the rows of the table above), restart the repo, and check the restored content.VFSNotebookRepoTest: saving over an existing note replaces its content and leaves only the.zplnfile.Screenshots (if appropriate)
N/A
Questions: