[ZEPPELIN-5858] Fix moveNote/saveNote race that duplicates notes - #5325
[ZEPPELIN-5858] Fix moveNote/saveNote race that duplicates notes#5325HwangRock wants to merge 3 commits into
Conversation
b529012 to
0a1ccf2
Compare
…erations
### What is this PR for?
`NoteManager` locates a note through two separate pieces of state: `notesInfo` maps a note id to its path, and `root` holds the folder tree that the path is walked against. A lookup uses both in sequence, so the two have to agree.
`reloadNotes()` replaced them one at a time:
```java
public void reloadNotes() throws IOException {
this.root = new Folder("/", notebookRepo, noteCache, zConf); // (1) tree becomes empty
this.trash = this.root.getOrCreateFolder(TRASH_FOLDER);
init(); // (2) new mapping, (3) refill tree
}
```
Neither field is `volatile` and nothing is held while they are swapped, so a concurrent `processNote()` can observe a mapping and a tree that belong to different generations:
| time | reloading thread | note request thread | state |
|---|---|---|---|
| t1 | installs an empty tree | | mapping: old (complete) / tree: **empty** |
| t2 | | `notesInfo.containsKey(noteId)` passes | the id is still in the old mapping |
| t3 | | walks the path in the tree, finds nothing | **throws** |
| t4 | installs the new mapping | | |
| t5 | refills the tree, one note at a time | | notes not inserted yet still fail |
The guard in `processNote()` only checks `notesInfo`, so it passes and the failure surfaces one line later in `getNoteNode()`:
```
java.io.IOException: Can not find note: /E2E_TEST_FOLDER/TestNotebook_...
at org.apache.zeppelin.notebook.NoteManager.getNoteNode
at org.apache.zeppelin.notebook.NoteManager.processNote
at org.apache.zeppelin.rest.NotebookRestApi.updateParagraph
```
`IOException` is not mapped to a specific status, so `WebApplicationExceptionMapper` turns it into **HTTP 500** for a note that was never removed. Everything that goes through `processNote()` is affected: reading a note, updating a paragraph, creating, deleting and moving notes, and listing the notebook.
This PR holds the tree, the trash folder and the mapping in one immutable `NoteTree` and publishes it with a single `volatile` write. `buildNoteTree()` fills the new tree locally and returns it; only then is it assigned. The tree-walking helpers (`getNoteNode`, `getFolder`, `getOrCreateFolder`, `isNotePathAvailable`) take the tree as a parameter, and callers that need both pieces of state read the reference once, so a lookup resolves the mapping and the tree against the same generation. Those helpers are `static` so that the compiler prevents them from reaching back to the field.
### Scope and related issues
**#5325** (`[ZEPPELIN-5858]`) is open against the same class and restructures `removeNote`, `moveNote` and `moveFolder` with `synchronized (this)`. It targets a different race (two mutators duplicating a note) and its monitor does not cover `reloadNotes()`, so neither change subsumes the other. Whichever merges second will need a rebase.
### What type of PR is it?
Bug Fix
### Todos
* [x] - Build the new tree, trash folder and mapping in `buildNoteTree()` before publishing them
* [x] - Hold the three in an immutable `NoteTree` published through a single `volatile` write
* [x] - Pass the tree into the tree-walking helpers so one lookup uses one generation
* [x] - Add a regression test that reloads while other threads read notes
* [x] - Confirm the test fails without the fix and passes with it
### What is the Jira issue?
* [ZEPPELIN-6579](https://issues.apache.org/jira/browse/ZEPPELIN-6579)
### How should this be tested?
New test `NoteManagerTest#testConcurrentReloadAndProcessNote`: it saves 50 notes, then runs `reloadNotes()` in a loop on one thread while four threads keep calling `processNote()` for every note, and asserts that no lookup fails or returns nothing.
```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 11)
./mvnw package -pl zeppelin-server --am -Dtest=NoteManagerTest -DfailIfNoTests=false
```
Result with the fix: `Tests run: 7, Failures: 0, Errors: 0`.
Reverting only the production change makes the new test fail on every reader thread with `java.io.IOException: Can not find note: /prod/note_0` thrown from `NoteManager.getNoteNode` via `NoteManager.processNote`, which is the stack from the ticket; with the fix it passes.
Also run, to cover the callers of the reload path:
```bash
./mvnw package -pl zeppelin-server --am \
-Dtest='NotebookTest#testReloadAllNotes+testReloadAndSetInterpreter' -DfailIfNoTests=false
```
Result: `Tests run: 2, Failures: 0, Errors: 0`.
Not verified locally: the full `NotebookTest` and `NotebookServerTest` classes, which start real remote interpreter processes and time out in my environment, and `NotebookRepoSyncTest`. Those are left to CI.
### Screenshots (if appropriate)
N/A
### Questions:
* Does the license files need to update? No
* Is there breaking changes for older versions? No
* Does this needs documentation? No
Closes #5357 from big-cir/ZEPPELIN-6579.
Signed-off-by: Jongyoul Lee <jongyoul@gmail.com>
|
Could you please rebase this onto master branch? |
Port the reproduction attached to ZEPPELIN-5858 (2022) onto the current NotebookRepo API. VFSNotebookRepoWithDelay extends VFSNotebookRepo and injects a delay after save() resolves its destination file name and after move() starts, simulating slow repo I/O such as remote storage. The test runs renameNote and insertParagraph concurrently against it. The save thread derives the note file name from stale path data, so after the move completes it resurrects the note at its old path and the same noteId ends up with two .zpln files: Saving note 2MXKZT37A to folder_1/note_2MXKZT37A.zpln Move note 2MXKZT37A to /folder_2/note Saving note 2MXKZT37A to folder_1/note_2MXKZT37A.zpln -> [folder_2/note_2MXKZT37A.zpln, folder_1/note_2MXKZT37A.zpln] The test walks the notebook directory and currently fails with two .zpln files for the same noteId.
…ote race moveNote mutated the folder tree, the notesInfo mapping and the repo without holding any lock, while saveNote derived the target file name from the path carried by the Note object. A save racing with a move could therefore write the note back to its pre-move path, leaving the same noteId on disk twice. moveNote, removeNote and moveFolder now perform their tree, mapping and repo mutations inside the monitor saveNote already synchronizes on, so the mutual exclusion holds against the save path. moveNote updates the cached note path directly through the note cache instead of processNote: processNote acquires the note's readLock, and taking it while holding the monitor would invert the fixed lock order (readLock -> monitor) used by every save caller. Since save callers and moveNote share the cached Note instance, a save that lost the monitor to a concurrent move then derives its file name from the already-updated path. The rename-triggered resave stays outside the monitor, reuses saveNote, and realigns the reloaded note with the move target first: a note evicted from the cache is reloaded from disk at that point, and the on-disk JSON still carries the pre-move name.
0a1ccf2 to
b8a4c22
Compare
|
@tbonelee Rebased onto master. Tests still pass, thanks. |
There was a problem hiding this comment.
Thanks for bringing this one with an executable reproduction rather than just a description. I ran a few things before reviewing:
| what I ran | result |
|---|---|
PR head b8a4c22 as-is |
NotebookServiceRaceConditionTest, NoteManagerTest, NotebookServiceTest: 13/13 pass |
revert only NoteManager.java to master |
fails with [folder_2/note_2N1CCKM4T.zpln, folder_1/note_2N1CCKM4T.zpln] |
The diagnosis and the fix line up. LGTM.
I left one optional note on the resave block. Nothing that should hold this up.
| // A note evicted from the cache is reloaded from disk here, and the on-disk | ||
| // JSON still carries the pre-move name, so realign the reloaded note with | ||
| // the move target before persisting it. | ||
| note.setPath(newNotePath); |
There was a problem hiding this comment.
I might be reading this wrong, so take it with a grain of salt.
This resave runs outside the monitor, so my reading is that if another move lands in between, newNotePath may no longer be the current one. In a synthetic setup, where I pin the interleaving with latches, that does show up: master passes and this branch ends up with two files.
expected: <[folder_2/renamed_x.zpln]>
but was: <[folder_1/renamed_x.zpln, folder_2/renamed_x.zpln]>
But I went through the callers afterwards and could not find a path that actually produces this combination, so it may be unreachable in practice. If it is already ruled out by something I did not look at, please just ignore this.
One option, if you think it is worth anything: newNotePath is fixed at call time whereas notesInfo holds the value as of the read, so reading the latter might be slightly more robust.
String currentPath = this.noteTree.notesInfo.get(noteId);
if (currentPath != null) {
note.setPath(currentPath);
saveNote(note, subject);
}One thing I did confirm, in case it saves you time: dropping the line on its own does not work. NoteManagerTest#testConcurrentOperation starts failing (expected: </dev/project_0/my_note0> but was: </dev/project_0/note0>). With the form above, the three classes in the table above still pass.
If you want to look at the interleaving: set zeppelin.note.cache.threshold to 1 and create a filler note so the note under test gets evicted. Thread 1 calls notebook.moveNote(id, "/folder_1/renamed") and parks inside NotebookRepo#get during the resave. Thread 2 then runs notebook.moveNote(id, "/folder_2/renamed") to completion, which has no resave of its own because the name is unchanged. Then let thread 1 continue. Happy to share the test if that is useful.
Genuinely fine to leave as is.
There was a problem hiding this comment.
@tbonelee Thanks for running it before reviewing — that saved me a lot of guessing here.
You were reading it right. I ported your setup into a test (threshold at 1, a filler note to force the eviction, a latch inside NotebookRepo#get so thread 1 parks there while thread 2 finishes) and it fails the same way on every run:
Expected exactly one .zpln file for note 2MZVA3AWG,
but found: [folder_2/renamed_2MZVA3AWG.zpln, folder_1/renamed_2MZVA3AWG.zpln]
On whether it is reachable — I think it is. You need one move that renames and one that does not, and the second kind exists on the trash paths: restoreNote() in NotebookService.java:697 only strips /~Trash and keeps the leaf name, and restoreFolder goes through moveNote() in Notebook.java:585 per note, same thing. Meanwhile renameNote() in NotebookService.java:302 calls moveNote from inside processNote, which holds the note's read lock, and that one is shared — so two callers can be inside at the same time. A rename racing a trash restore lands on exactly your interleaving.
About the form you suggested — reading notesInfo is the right idea, but I don't think it closes the window on its own. The read and saveNote taking the monitor are still two separate steps, so a move landing in between writes the stale path again. I instrumented that read point and it does reproduce. Your latch sits just before the read, which is why it passes there.
So I'd rather put the read and the save in the same monitor section, along with the null guard processNote wants when the id is already gone:
processNote(noteId, note -> {
if (note == null) {
return null;
}
synchronized (this) {
String currentPath = this.noteTree.notesInfo.get(noteId);
if (currentPath != null) {
note.setPath(currentPath);
saveNote(note, subject);
}
}
return null;
});Lock order stays as it was — processNote takes the read lock first and the monitor second — and the only extra work under the monitor is a map lookup, since saveNote already does its repo write in there.
Two things I found while digging into this that I'd rather keep out of this PR: removeFolder and addNote are still unsynchronized, which is an asymmetry this PR introduces, and the resave only exists because loadAndProcessNote derives the leaf name from the JSON name instead of from the tree. Happy to file both separately.
I have the fix and the reproduction ready — let me know if this direction looks right and I'll push.
There was a problem hiding this comment.
Direction looks right to me, please push.
You are right that reading notesInfo on its own leaves the read and the save as two steps. I had that in an earlier draft and trimmed it before posting, which I should not have. The note == null guard is a good addition too.
Both follow-ups sound good, please go ahead.
There was a problem hiding this comment.
@tbonelee Pushed as e3f715b. Filed the follow-ups as ZEPPELIN-6594 and ZEPPELIN-6595.
NoteManagerMoveResaveRaceTest pins the interleaving — fails on the previous commit, passes on this one. All 14 tests across the four classes green.
The rename-triggered resave in moveNote read newNotePath, an argument fixed at method entry, and used it to set the reloaded note's path before saving. If a second moveNote call for the same noteId completed while the first call's resave was still reloading the note from disk, the first call resumed with a destination that was no longer current and saved the note back at that stale path, leaving two .zpln files for the same noteId. The resave now reads the note's current path from the notesInfo mapping instead of trusting the method argument, and does the read and the save inside the same monitor section so no concurrent move can land between them. It also guards against note being null, which processNote passes in when the noteId has already left the mapping (e.g. a concurrent removeNote/removeFolder). Also corrects a comment above the direct cache access in moveNote: it claimed processNote's readLock acquisition would invert the fixed lock order against the monitor, but a reverse edge (monitor -> note writeLock) already exists via saveNote -> noteCache.putNote() -> LRU eviction. That edge is harmless only because every note writeLock acquisition in NoteCache uses tryLock(), which never blocks. The comment now records this invariant instead of the inaccurate one. Adds NoteManagerMoveResaveRaceTest, which reproduces the race deterministically with VFSNotebookRepoWithGetGate by pinning the first moveNote call's reload right after it reads the note from disk.
dc7c4a7 to
e3f715b
Compare
What is this PR for?
moveNotemutates the folder tree, thenotesInfomapping and the notebook repo without holding any lock, whilesaveNotederives its target file name from the path carried by theNoteobject at save time. A save racing with a move therefore writes the note back to its pre-move path, and the same noteId ends up on disk twice. ZEPPELIN-5858 describes exactly this and ships a reproduction; this PR ports that reproduction onto the current API and fixes the race.Reproduced on current master with the ported test (
VFSNotebookRepoWithDelayextendsVFSNotebookRepoand injects repo latency, e.g. remote storage):The fix serializes NoteManager's persistent mutations:
moveNote,removeNoteandmoveFoldernow run their tree, mapping and repo mutations inside the monitorsaveNotealready synchronizes on. Save callers andmoveNoteshare the cachedNoteinstance, so a save that loses the monitor to a concurrent move derives its file name from the already-updated path once it enters — no note is written to its pre-move location.saveNote's existing contract (theNoteobject's path is authoritative;addOrUpdateNoteNodesyncs the mapping to it) is deliberately left untouched, since rename-by-save flows likeNotebook.updateNoteand note import depend on it.Two deliberate details:
readLock-> NoteManager monitor, because every save caller runs insideprocessNoteholding the note's readLock.moveNotetherefore updates the cached note path directly via the note cache instead of callingprocessNotewhile holding the monitor, and the rename-triggered resave stays outside the monitor and reusessaveNote. This keeps the lock order consistent on every path.Verified the causality both ways: the reproduction test fails with two
.zplnfiles for the same noteId on master, passes with this change, and fails again if only theNoteManagerchange is reverted.What type of PR is it?
Bug Fix
Todos
What is the Jira issue?
https://issues.apache.org/jira/browse/ZEPPELIN-5858
How should this be tested?
mvn test -pl zeppelin-server -Dtest=NotebookServiceRaceConditionTest— the test runsrenameNoteandinsertParagraphconcurrently against a delay-injectingVFSNotebookReposubclass, then walks the notebook directory and asserts exactly one.zplnfile remains. Without theNoteManagerchange it finds the same noteId at both the old and the new path.Regression:
NoteManagerTest,NotebookServiceTest,NotebookTest,LuceneSearchTest,ZeppelinRestApiTest(86 tests) all pass.Questions: