This is an automated email from the ASF dual-hosted git repository.
jongyoul pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git
The following commit(s) were added to refs/heads/master by this push:
new d8c43cb156 [ZEPPELIN-6579] Make notebook tree reload safe for
concurrent note operations
d8c43cb156 is described below
commit d8c43cb1561bddc296f9a158eafe8477c488625f
Author: dae won <[email protected]>
AuthorDate: Sat Aug 1 13:16:51 2026 +0900
[ZEPPELIN-6579] Make notebook tree reload safe for concurrent note
operations
### 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 generatio [...]
### 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 <[email protected]>
---
.../org/apache/zeppelin/notebook/NoteManager.java | 155 +++++++++++++--------
.../apache/zeppelin/notebook/NoteManagerTest.java | 59 ++++++++
2 files changed, 158 insertions(+), 56 deletions(-)
diff --git
a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java
b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java
index fffb49d8ca..0635fde994 100644
---
a/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java
+++
b/zeppelin-server/src/main/java/org/apache/zeppelin/notebook/NoteManager.java
@@ -59,54 +59,62 @@ import io.micrometer.core.instrument.Tags;
public class NoteManager {
private static final Logger LOGGER =
LoggerFactory.getLogger(NoteManager.class);
public static final String TRASH_FOLDER = "~Trash";
- private Folder root;
- private Folder trash;
-
private NotebookRepo notebookRepo;
private NoteCache noteCache;
- // noteId -> notePath
- private Map<String, String> notesInfo;
private final ZeppelinConfiguration zConf;
+ /**
+ * The folder tree and the noteId -> notePath mapping. They are held
together in one
+ * immutable reference so that a reload publishes both at once and
concurrent note
+ * operations never observe a tree and a mapping that belong to different
generations.
+ */
+ private volatile NoteTree noteTree;
+
@Inject
public NoteManager(NotebookRepo notebookRepo, ZeppelinConfiguration zConf)
throws IOException {
this.zConf = zConf;
this.notebookRepo = notebookRepo;
this.noteCache = new NoteCache(zConf.getNoteCacheThreshold());
- this.root = new Folder("/", notebookRepo, noteCache, zConf);
- this.trash = this.root.getOrCreateFolder(TRASH_FOLDER);
- init();
+ this.noteTree = buildNoteTree();
}
- // build the tree structure of notes
- private void init() throws IOException {
- this.notesInfo =
notebookRepo.list(AuthenticationInfo.ANONYMOUS).values().stream()
- .collect(Collectors.toConcurrentMap(NoteInfo::getId,
NoteInfo::getPath));
- this.notesInfo.entrySet().stream()
- .forEach(entry ->
- {
- try {
- addOrUpdateNoteNode(new NoteInfo(entry.getKey(),
entry.getValue()));
- } catch (IOException e) {
- LOGGER.warn(e.getMessage());
- }
- });
+ /**
+ * Build the tree structure of notes from the NotebookRepo. The tree is
fully populated
+ * before it is returned, and it is not reachable by other threads until the
caller
+ * publishes it to {@link #noteTree}.
+ */
+ private NoteTree buildNoteTree() throws IOException {
+ Folder newRoot = new Folder("/", notebookRepo, noteCache, zConf);
+ Folder newTrash = newRoot.getOrCreateFolder(TRASH_FOLDER);
+ Map<String, String> newNotesInfo =
+ notebookRepo.list(AuthenticationInfo.ANONYMOUS).values().stream()
+ .collect(Collectors.toConcurrentMap(NoteInfo::getId,
NoteInfo::getPath));
+ NoteTree newNoteTree = new NoteTree(newRoot, newTrash, newNotesInfo);
+ for (Map.Entry<String, String> entry : newNotesInfo.entrySet()) {
+ try {
+ addOrUpdateNoteNode(newNoteTree, new NoteInfo(entry.getKey(),
entry.getValue()), false);
+ } catch (IOException e) {
+ LOGGER.warn(e.getMessage());
+ }
+ }
+ return newNoteTree;
}
public Map<String, String> getNotesInfo() {
- return notesInfo;
+ return this.noteTree.notesInfo;
}
/**
+ * Rebuild the notebook metadata from the NotebookRepo. The new tree is
built completely
+ * before it replaces the current one, so a concurrent note operation sees
either the
+ * previous tree or the new one, never a partially rebuilt tree.
*
* @throws IOException
*/
public void reloadNotes() throws IOException {
- this.root = new Folder("/", notebookRepo, noteCache, zConf);
- this.trash = this.root.getOrCreateFolder(TRASH_FOLDER);
- init();
+ this.noteTree = buildNoteTree();
}
/**
@@ -117,15 +125,16 @@ public class NoteManager {
return this.noteCache.getSize();
}
- private void addOrUpdateNoteNode(NoteInfo noteInfo, boolean checkDuplicates)
throws IOException {
+ private void addOrUpdateNoteNode(NoteTree tree, NoteInfo noteInfo, boolean
checkDuplicates)
+ throws IOException {
String notePath = noteInfo.getPath();
- if (checkDuplicates && !isNotePathAvailable(notePath)) {
+ if (checkDuplicates && !isNotePathAvailable(tree, notePath)) {
throw new NotePathAlreadyExistsException("Note '" + notePath + "'
existed");
}
String[] tokens = notePath.split("/");
- Folder curFolder = root;
+ Folder curFolder = tree.root;
for (int i = 0; i < tokens.length - 1; ++i) {
if (!StringUtils.isBlank(tokens[i])) {
curFolder = curFolder.getOrCreateFolder(tokens[i]);
@@ -133,11 +142,7 @@ public class NoteManager {
}
curFolder.addNote(tokens[tokens.length -1], noteInfo);
- this.notesInfo.put(noteInfo.getId(), noteInfo.getPath());
- }
-
- private void addOrUpdateNoteNode(NoteInfo noteInfo) throws IOException {
- addOrUpdateNoteNode(noteInfo, false);
+ tree.notesInfo.put(noteInfo.getId(), noteInfo.getPath());
}
/**
@@ -182,7 +187,7 @@ public class NoteManager {
if (note.isRemoved()) {
LOGGER.warn("Try to save note: {} when it is removed", note.getId());
} else {
- addOrUpdateNoteNode(new NoteInfo(note));
+ addOrUpdateNoteNode(this.noteTree, new NoteInfo(note), false);
noteCache.putNote(note);
// Make sure to execute `notebookRepo.save()` successfully in concurrent
context
// Otherwise, the NullPointerException will be thrown when invoking
notebookRepo.get() in the following operations.
@@ -193,7 +198,7 @@ public class NoteManager {
}
public void addNote(Note note, AuthenticationInfo subject) throws
IOException {
- addOrUpdateNoteNode(new NoteInfo(note), true);
+ addOrUpdateNoteNode(this.noteTree, new NoteInfo(note), true);
noteCache.putNote(note);
}
@@ -215,8 +220,9 @@ public class NoteManager {
* @throws IOException
*/
public void removeNote(String noteId, AuthenticationInfo subject) throws
IOException {
- String notePath = this.notesInfo.remove(noteId);
- Folder folder = getOrCreateFolder(getFolderName(notePath));
+ NoteTree tree = this.noteTree;
+ String notePath = tree.notesInfo.remove(noteId);
+ Folder folder = getOrCreateFolder(tree, getFolderName(notePath));
folder.removeNote(getNoteName(notePath));
noteCache.removeNote(noteId);
this.notebookRepo.remove(noteId, notePath, subject);
@@ -229,21 +235,22 @@ public class NoteManager {
throw new IOException("No metadata found for this note: " + noteId);
}
- if (!isNotePathAvailable(newNotePath)) {
+ NoteTree tree = this.noteTree;
+ if (!isNotePathAvailable(tree, newNotePath)) {
throw new NotePathAlreadyExistsException("Note '" + newNotePath + "'
existed");
}
// move the old NoteNode from notePath to newNotePath
- String notePath = this.notesInfo.get(noteId);
- NoteNode noteNode = getNoteNode(notePath);
+ String notePath = tree.notesInfo.get(noteId);
+ NoteNode noteNode = getNoteNode(tree, notePath);
noteNode.getParent().removeNote(getNoteName(notePath));
noteNode.setNotePath(newNotePath);
String newParent = getFolderName(newNotePath);
- Folder newFolder = getOrCreateFolder(newParent);
+ Folder newFolder = getOrCreateFolder(tree, newParent);
newFolder.addNoteNode(noteNode);
// update noteInfo mapping
- this.notesInfo.put(noteId, newNotePath);
+ tree.notesInfo.put(noteId, newNotePath);
// update notebookrepo
this.notebookRepo.move(noteId, notePath, newNotePath, subject);
@@ -277,14 +284,15 @@ public class NoteManager {
this.notebookRepo.move(folderPath, newFolderPath, subject);
// update filesystem tree
- Folder folder = getFolder(folderPath);
+ NoteTree tree = this.noteTree;
+ Folder folder = getFolder(tree, folderPath);
folder.getParent().removeFolder(folder.getName(), subject);
- Folder newFolder = getOrCreateFolder(newFolderPath);
+ Folder newFolder = getOrCreateFolder(tree, newFolderPath);
newFolder.getParent().addFolder(newFolder.getName(), folder);
// update notesInfo
for (NoteInfo noteInfo : folder.getNoteInfoRecursively()) {
- notesInfo.put(noteInfo.getId(), noteInfo.getPath());
+ tree.notesInfo.put(noteInfo.getId(), noteInfo.getPath());
}
}
@@ -313,12 +321,13 @@ public class NoteManager {
this.notebookRepo.remove(folderPath, subject);
// update filesystem tree
- Folder folder = getFolder(folderPath);
+ NoteTree tree = this.noteTree;
+ Folder folder = getFolder(tree, folderPath);
List<NoteInfo> noteInfos =
folder.getParent().removeFolder(folder.getName(), subject);
// update notesInfo and evict the deleted notes from the cache, mirroring
removeNote
for (NoteInfo noteInfo : noteInfos) {
- this.notesInfo.remove(noteInfo.getId());
+ tree.notesInfo.remove(noteInfo.getId());
this.noteCache.removeNote(noteInfo.getId());
}
@@ -336,11 +345,14 @@ public class NoteManager {
*/
public <T> T processNote(String noteId, boolean reload, NoteProcessor<T>
noteProcessor)
throws IOException {
- if (this.notesInfo == null || noteId == null ||
!this.notesInfo.containsKey(noteId)) {
+ // Read the tree once, so that the mapping lookup below and the tree
traversal that
+ // follows it are both resolved against the same generation of the
metadata.
+ NoteTree tree = this.noteTree;
+ if (tree == null || noteId == null || !tree.notesInfo.containsKey(noteId))
{
return noteProcessor.process(null);
}
- String notePath = this.notesInfo.get(noteId);
- NoteNode noteNode = getNoteNode(notePath);
+ String notePath = tree.notesInfo.get(noteId);
+ NoteNode noteNode = getNoteNode(tree, notePath);
return noteNode.loadAndProcessNote(reload, noteProcessor);
}
@@ -362,8 +374,12 @@ public class NoteManager {
* @return
*/
public Folder getOrCreateFolder(String folderName) {
+ return getOrCreateFolder(this.noteTree, folderName);
+ }
+
+ private static Folder getOrCreateFolder(NoteTree tree, String folderName) {
String[] tokens = folderName.split("/");
- Folder curFolder = root;
+ Folder curFolder = tree.root;
for (int i = 0; i < tokens.length; ++i) {
if (!StringUtils.isBlank(tokens[i])) {
curFolder = curFolder.getOrCreateFolder(tokens[i]);
@@ -373,8 +389,12 @@ public class NoteManager {
}
private NoteNode getNoteNode(String notePath) throws IOException {
+ return getNoteNode(this.noteTree, notePath);
+ }
+
+ private static NoteNode getNoteNode(NoteTree tree, String notePath) throws
IOException {
String[] tokens = notePath.split("/");
- Folder curFolder = root;
+ Folder curFolder = tree.root;
for (int i = 0; i < tokens.length - 1; ++i) {
if (!StringUtils.isBlank(tokens[i])) {
curFolder = curFolder.getFolder(tokens[i]);
@@ -391,8 +411,12 @@ public class NoteManager {
}
private Folder getFolder(String folderPath) throws IOException {
+ return getFolder(this.noteTree, folderPath);
+ }
+
+ private static Folder getFolder(NoteTree tree, String folderPath) throws
IOException {
String[] tokens = folderPath.split("/");
- Folder curFolder = root;
+ Folder curFolder = tree.root;
for (int i = 0; i < tokens.length; ++i) {
if (!StringUtils.isBlank(tokens[i])) {
curFolder = curFolder.getFolder(tokens[i]);
@@ -405,7 +429,7 @@ public class NoteManager {
}
public Folder getTrashFolder() {
- return this.trash;
+ return this.noteTree.trash;
}
private String getFolderName(String notePath) {
@@ -418,9 +442,9 @@ public class NoteManager {
return notePath.substring(pos + 1);
}
- private boolean isNotePathAvailable(String notePath) {
+ private static boolean isNotePathAvailable(NoteTree tree, String notePath) {
String[] tokens = notePath.split("/");
- Folder curFolder = root;
+ Folder curFolder = tree.root;
for (int i = 0; i < tokens.length - 1; ++i) {
if (!StringUtils.isBlank(tokens[i])) {
curFolder = curFolder.getFolder(tokens[i]);
@@ -441,6 +465,25 @@ public class NoteManager {
return noteNode.getNoteId();
}
+ /**
+ * The two indexes that together locate a note: the folder tree and the
noteId -> notePath
+ * mapping. A note lookup resolves the id through the mapping and then walks
the tree, so
+ * the two must belong to the same generation. Holding them in one immutable
reference lets
+ * a reload replace both of them in a single assignment.
+ */
+ private static class NoteTree {
+ private final Folder root;
+ private final Folder trash;
+ // noteId -> notePath
+ private final Map<String, String> notesInfo;
+
+ NoteTree(Folder root, Folder trash, Map<String, String> notesInfo) {
+ this.root = root;
+ this.trash = trash;
+ this.notesInfo = notesInfo;
+ }
+ }
+
/**
* Represent one folder that could contains sub folders and note files.
*/
diff --git
a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java
b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java
index cb23ea8f16..eaed222f9e 100644
---
a/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java
+++
b/zeppelin-server/src/test/java/org/apache/zeppelin/notebook/NoteManagerTest.java
@@ -25,15 +25,20 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -208,6 +213,60 @@ class NoteManagerTest {
threadPool.shutdown();
}
+ @Test
+ void testConcurrentReloadAndProcessNote() throws Exception {
+ int noteNum = 50, readerNum = 4, reloadRounds = 30;
+ Map<Integer, String> notes = new ConcurrentHashMap<>();
+ for (int i = 0; i < noteNum; i++) {
+ Note note = createNote(String.format("/prod/note_%s", i));
+ noteManager.saveNote(note);
+ notes.put(i, note.getId());
+ }
+
+ List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());
+ AtomicBoolean reloading = new AtomicBoolean(true);
+ ExecutorService threadPool = Executors.newFixedThreadPool(readerNum + 1);
+ CountDownLatch done = new CountDownLatch(readerNum + 1);
+
+ // Reload the whole note tree repeatedly while other threads read the notes
+ threadPool.execute(() -> {
+ try {
+ for (int i = 0; i < reloadRounds; i++) {
+ noteManager.reloadNotes();
+ }
+ } catch (Throwable t) {
+ failures.add(t);
+ } finally {
+ reloading.set(false);
+ done.countDown();
+ }
+ });
+
+ for (int i = 0; i < readerNum; i++) {
+ threadPool.execute(() -> {
+ try {
+ while (reloading.get()) {
+ for (String noteId : notes.values()) {
+ assertNotNull(noteManager.processNote(noteId, note -> note),
+ "processNote() found no note for an existing noteId during
reload");
+ }
+ }
+ } catch (Throwable t) {
+ failures.add(t);
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+
+ assertTrue(done.await(60, TimeUnit.SECONDS), "Concurrent reload did not
finish in time");
+ threadPool.shutdown();
+ if (!failures.isEmpty()) {
+ throw new AssertionError(failures.size()
+ + " note operation(s) failed while the note tree was being
reloaded", failures.get(0));
+ }
+ }
+
abstract class ConcurrentTask {
private ExecutorService threadPool;
private int noteNum;