Skip to content

[ZEPPELIN-5858] Fix moveNote/saveNote race that duplicates notes - #5325

Open
HwangRock wants to merge 3 commits into
apache:masterfrom
HwangRock:ZEPPELIN-5858
Open

[ZEPPELIN-5858] Fix moveNote/saveNote race that duplicates notes#5325
HwangRock wants to merge 3 commits into
apache:masterfrom
HwangRock:ZEPPELIN-5858

Conversation

@HwangRock

@HwangRock HwangRock commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What is this PR for?

moveNote mutates the folder tree, the notesInfo mapping and the notebook repo without holding any lock, while saveNote derives its target file name from the path carried by the Note object 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 (VFSNotebookRepoWithDelay extends VFSNotebookRepo and injects repo latency, e.g. remote storage):

Saving note 2MXKZT37A to folder_1/note_2MXKZT37A.zpln
Move note 2MXKZT37A to /folder_2/note
Move note 2MXKZT37A from /folder_1/note to /folder_2/note
Saving note 2MXKZT37A to folder_1/note_2MXKZT37A.zpln   <- stale path, resurrects the old file

Expected exactly one .zpln file, but found:
  [folder_2/note_2MXKZT37A.zpln, folder_1/note_2MXKZT37A.zpln]

The fix serializes NoteManager's persistent mutations: moveNote, removeNote and moveFolder now run their tree, mapping and repo mutations inside the monitor saveNote already synchronizes on. Save callers and moveNote share the cached Note instance, 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 (the Note object's path is authoritative; addOrUpdateNoteNode syncs the mapping to it) is deliberately left untouched, since rename-by-save flows like Notebook.updateNote and note import depend on it.

Two deliberate details:

  • The fixed lock order in this code base is note readLock -> NoteManager monitor, because every save caller runs inside processNote holding the note's readLock. moveNote therefore updates the cached note path directly via the note cache instead of calling processNote while holding the monitor, and the rename-triggered resave stays outside the monitor and reuses saveNote. This keeps the lock order consistent on every path.
  • The rename-triggered resave realigns the reloaded note with the move target before saving: a note evicted from the cache is reloaded from disk at that point, and the on-disk JSON still carries the pre-move name.

Verified the causality both ways: the reproduction test fails with two .zpln files for the same noteId on master, passes with this change, and fails again if only the NoteManager change is reverted.

What type of PR is it?

Bug Fix

Todos

  • Port the reproduction attached to ZEPPELIN-5858 onto the current NotebookRepo API
  • Serialize NoteManager mutations

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 runs renameNote and insertParagraph concurrently against a delay-injecting VFSNotebookRepo subclass, then walks the notebook directory and asserts exactly one .zpln file remains. Without the NoteManager change it finds the same noteId at both the old and the new path.

Regression: NoteManagerTest, NotebookServiceTest, NotebookTest, LuceneSearchTest, ZeppelinRestApiTest (86 tests) all pass.

Questions:

  • Does the license files need to update? No
  • Is there breaking changes for older versions? No
  • Does this needs documentation? No

@HwangRock
HwangRock force-pushed the ZEPPELIN-5858 branch 2 times, most recently from b529012 to 0a1ccf2 Compare July 19, 2026 10:55
jongyoul pushed a commit that referenced this pull request Aug 1, 2026
…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>
@tbonelee

tbonelee commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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.
@HwangRock

Copy link
Copy Markdown
Contributor Author

@tbonelee Rebased onto master. Tests still pass, thanks.

tbonelee
tbonelee previously approved these changes Aug 2, 2026

@tbonelee tbonelee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants