This is an automated email from the ASF dual-hosted git repository.
ParkGyeongTae 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 fb7f9dc86f [ZEPPELIN-6540] Make ConnectionManager.userSocketMap
thread-safe
fb7f9dc86f is described below
commit fb7f9dc86f2966395610cf8fa7942f6a56abf274
Author: HwangRock <[email protected]>
AuthorDate: Tue Jul 21 22:48:54 2026 +0900
[ZEPPELIN-6540] Make ConnectionManager.userSocketMap thread-safe
### What is this PR for?
`ConnectionManager.userSocketMap` (`user -> Queue<NotebookSocket>`) is a
plain `HashMap`, but every access to it is unsynchronized:
`addUserConnection`/`removeUserConnection` (writes) and
`multicastToUser`/`unicastParagraph`/`forAllUsers`/`broadcastNoteListExcept`
(reads and `keySet()` iteration). The sibling field `noteSocketMap` is guarded
by `synchronized (noteSocketMap)` at every access — only `userSocketMap` was
left unprotected.
These paths run on different Jetty WebSocket threads (login `onMessage`,
disconnect `onClose`, note-list broadcast `broadcastNoteListUpdate`). Under
concurrent connect/disconnect — e.g. a burst of client reconnects after a
server restart — this leads to:
- `ConcurrentModificationException` while iterating `keySet()`, aborting
the note-list broadcast so some users stop receiving updates
- possible CPU spin / lost entries during `HashMap` resize
- `NullPointerException` from the `containsKey` + `get` TOCTOU in
`multicastToUser`/`unicastParagraph`
The map value is already a `ConcurrentLinkedQueue`, so only the map itself
was unprotected.
Fix: switch `userSocketMap` to `ConcurrentHashMap` and make the compound
operations atomic:
- `addUserConnection`: `compute(...)` so the queue create-or-reuse and the
`add` happen in one atomic map operation (closing the add-after-remove window
that a bare `computeIfAbsent(...).add(...)` would leave open)
- `removeUserConnection`: `computeIfPresent(...)`, removing the key when
the queue becomes empty
- `multicastToUser` / `unicastParagraph`: a single `get()` + null check,
removing the TOCTOU/NPE
- `forAllUsers` / `broadcastNoteListExcept`: unchanged — `keySet()`
iteration is safe under `ConcurrentHashMap`'s weakly-consistent iterator
`noteSocketMap` intentionally keeps `synchronized`: it needs multi-entry
atomic operations (`removeConnectionFromAllNote`, `checkCollaborativeStatus`)
that a per-key `ConcurrentHashMap` guarantee does not cover, so a
single-strategy migration would not be correct there.
### What type of PR is it?
Bug Fix
### Todos
* [ ] - none
### What is the Jira issue?
* https://issues.apache.org/jira/browse/ZEPPELIN-6540
### How should this be tested?
Added two unit tests in `ConnectionManagerTest`:
- `userSocketMapConcurrentAccessTest`: 8 writer threads add/remove
connections while 2 reader threads iterate via `forAllUsers`. On the old
`HashMap` this reproduces `ConcurrentModificationException` /
`NullPointerException`; with the fix it passes with no thrown exception.
- `userSocketMapConcurrentAddPreservesAllConnectionsTest`: 16 threads
concurrently add unique sockets for the same user, then assert every socket is
present and the final queue size matches — validates the atomic publish
`compute()` guarantees.
Ran the full `ConnectionManagerTest` 5× consecutively — stable, no
intermittent failures.
Note on the add-after-remove window: it is extremely narrow and did not
reproduce as a deterministic failing test even under heavy contention (16 churn
threads, 16×5000 iterations). The `compute()` fix closes it by construction
(atomic per-key create-and-add under `ConcurrentHashMap`); the correctness
rests on that plus the concurrent-add invariant test rather than a RED→GREEN of
the exact interleaving.
### Screenshots (if appropriate)
### Questions:
* Does the license files need to update? No.
* Is there breaking changes for older versions? No — public method
signatures are unchanged.
* Does this needs documentation? No.
Closes #5306 from HwangRock/ZEPPELIN-6540.
Signed-off-by: ParkGyeongTae <[email protected]>
---
.../apache/zeppelin/socket/ConnectionManager.java | 42 +++---
.../zeppelin/socket/ConnectionManagerTest.java | 142 +++++++++++++++++++++
2 files changed, 166 insertions(+), 18 deletions(-)
diff --git
a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/ConnectionManager.java
b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/ConnectionManager.java
index a348d218af..6b13613cce 100644
---
a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/ConnectionManager.java
+++
b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/ConnectionManager.java
@@ -53,6 +53,7 @@ import java.util.Map;
import java.util.Map.Entry;
import java.util.Queue;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
/**
@@ -71,7 +72,7 @@ public class ConnectionManager {
// noteId -> connection
final Map<String, Set<NotebookSocket>> noteSocketMap =
Metrics.gaugeMapSize("zeppelin_note_sockets", Tags.empty(), new HashMap<>());
// user -> connection
- final Map<String, Queue<NotebookSocket>> userSocketMap =
Metrics.gaugeMapSize("zeppelin_user_sockets", Tags.empty(), new HashMap<>());
+ final Map<String, Queue<NotebookSocket>> userSocketMap =
Metrics.gaugeMapSize("zeppelin_user_sockets", Tags.empty(), new
ConcurrentHashMap<>());
/**
* This is a special endpoint in the notebook websocket, Every connection in
this Queue
@@ -153,24 +154,27 @@ public class ConnectionManager {
public void addUserConnection(String user, NotebookSocket conn) {
LOGGER.debug("Add user connection {} for user: {}", conn, user);
conn.setUser(user);
- if (userSocketMap.containsKey(user)) {
- userSocketMap.get(user).add(conn);
- } else {
- Queue<NotebookSocket> socketQueue = new ConcurrentLinkedQueue<>();
- socketQueue.add(conn);
- userSocketMap.put(user, socketQueue);
- }
+ userSocketMap.compute(user, (k, connections) -> {
+ Queue<NotebookSocket> queue =
+ (connections == null) ? new ConcurrentLinkedQueue<>() : connections;
+ queue.add(conn);
+ return queue;
+ });
}
public void removeUserConnection(String user, NotebookSocket conn) {
LOGGER.debug("Remove user connection {} for user: {}", conn, user);
- if (userSocketMap.containsKey(user)) {
- Queue<NotebookSocket> connections = userSocketMap.get(user);
+ if (user == null) {
+ LOGGER.warn("Closing connection for null user");
+ return;
+ }
+ boolean[] wasPresent = {false};
+ userSocketMap.computeIfPresent(user, (k, connections) -> {
+ wasPresent[0] = true;
connections.remove(conn);
- if (connections.isEmpty()) {
- userSocketMap.remove(user);
- }
- } else {
+ return connections.isEmpty() ? null : connections;
+ });
+ if (!wasPresent[0]) {
LOGGER.warn("Closing connection that is absent in user connections");
}
}
@@ -330,12 +334,13 @@ public class ConnectionManager {
public void multicastToUser(String user, Message m) {
- if (!userSocketMap.containsKey(user)) {
+ Queue<NotebookSocket> connections = userSocketMap.get(user);
+ if (connections == null) {
LOGGER.warn("Multicasting to user {} that is not in connections map",
user);
return;
}
- for (NotebookSocket conn : userSocketMap.get(user)) {
+ for (NotebookSocket conn : connections) {
unicast(m, conn);
}
}
@@ -354,12 +359,13 @@ public class ConnectionManager {
return;
}
- if (!userSocketMap.containsKey(user)) {
+ Queue<NotebookSocket> connections = userSocketMap.get(user);
+ if (connections == null) {
LOGGER.warn("Failed to send unicast. user {} that is not in connections
map", user);
return;
}
- for (NotebookSocket conn : userSocketMap.get(user)) {
+ for (NotebookSocket conn : connections) {
Message m = new
Message(Message.OP.PARAGRAPH).withMsgId(msgId).put("paragraph", p);
unicast(m, conn);
}
diff --git
a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/ConnectionManagerTest.java
b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/ConnectionManagerTest.java
index 92adc93c79..562d065894 100644
---
a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/ConnectionManagerTest.java
+++
b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/ConnectionManagerTest.java
@@ -16,18 +16,25 @@
*/
package org.apache.zeppelin.socket;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.CopyOnWriteArrayList;
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.AtomicReference;
import org.apache.zeppelin.conf.ZeppelinConfiguration;
import org.apache.zeppelin.notebook.AuthorizationService;
@@ -142,6 +149,122 @@ class ConnectionManagerTest {
assertEquals(0, manager.watcherSockets.size());
}
+ @Test
+ void userSocketMapConcurrentAccessTest() throws InterruptedException {
+ AuthorizationService authService = mock(AuthorizationService.class);
+ when(authService.getRoles(anyString())).thenAnswer(invocation -> new
HashSet<>());
+ ConnectionManager manager = new ConnectionManager(authService,
ZeppelinConfiguration.load());
+
+ int writerCount = 8;
+ int readerCount = 2;
+ int iterations = 1000;
+
+ List<String> users = new ArrayList<>();
+ List<NotebookSocket> sockets = new ArrayList<>();
+ for (int i = 0; i < writerCount; i++) {
+ users.add("user-" + i);
+ sockets.add(mock(NotebookSocket.class));
+ }
+
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(writerCount + readerCount);
+ ExecutorService executor = Executors.newFixedThreadPool(writerCount +
readerCount);
+
+ for (int i = 0; i < writerCount; i++) {
+ String user = users.get(i);
+ NotebookSocket socket = sockets.get(i);
+ executor.submit(() -> {
+ try {
+ startLatch.await();
+ for (int j = 0; j < iterations; j++) {
+ manager.addUserConnection(user, socket);
+ manager.removeUserConnection(user, socket);
+ }
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ for (int i = 0; i < readerCount; i++) {
+ executor.submit(() -> {
+ try {
+ startLatch.await();
+ for (int j = 0; j < iterations; j++) {
+ manager.forAllUsers((user, userAndRoles) -> { });
+ }
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ startLatch.countDown();
+ assertTrue(doneLatch.await(30, TimeUnit.SECONDS));
+ executor.shutdown();
+
+ assertNull(failure.get(),
+ "Concurrent access to userSocketMap should not throw, but got: " +
failure.get());
+ }
+
+ @Test
+ void userSocketMapConcurrentAddPreservesAllConnectionsTest() throws
InterruptedException {
+ AuthorizationService authService = mock(AuthorizationService.class);
+ ConnectionManager manager = new ConnectionManager(authService,
ZeppelinConfiguration.load());
+
+ String user = "shared-user";
+ int threadCount = 16;
+ int iterationsPerThread = 500;
+
+ AtomicReference<Throwable> failure = new AtomicReference<>();
+ List<NotebookSocket> addedSockets = new CopyOnWriteArrayList<>();
+
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(threadCount);
+ for (int i = 0; i < threadCount; i++) {
+ executor.submit(() -> {
+ try {
+ startLatch.await();
+ for (int j = 0; j < iterationsPerThread; j++) {
+ NotebookSocket socket = mock(NotebookSocket.class);
+ manager.addUserConnection(user, socket);
+ addedSockets.add(socket);
+ }
+ } catch (Throwable t) {
+ failure.compareAndSet(null, t);
+ } finally {
+ doneLatch.countDown();
+ }
+ });
+ }
+
+ startLatch.countDown();
+ assertTrue(doneLatch.await(30, TimeUnit.SECONDS));
+ executor.shutdown();
+
+ assertNull(failure.get(), "Concurrent add should not throw, but got: " +
failure.get());
+
+ Queue<NotebookSocket> finalConnections = manager.userSocketMap.get(user);
+ assertEquals(threadCount * iterationsPerThread, addedSockets.size());
+ List<NotebookSocket> missing = new ArrayList<>();
+ for (NotebookSocket socket : addedSockets) {
+ if (finalConnections == null || !finalConnections.contains(socket)) {
+ missing.add(socket);
+ }
+ }
+
+ assertTrue(missing.isEmpty(),
+ missing.size() + " of " + addedSockets.size()
+ + " concurrently added connections were lost from userSocketMap");
+ assertEquals(addedSockets.size(), finalConnections.size());
+ }
+
@Test
void switchConnectionToWatcherAndRemove() {
AuthorizationService authService = mock(AuthorizationService.class);
@@ -168,4 +291,23 @@ class ConnectionManagerTest {
// Verify it's completely removed
assertFalse(manager.watcherSockets.contains(socket));
}
+
+ @Test
+ void removeUserConnectionWithNullUserDoesNotThrow() {
+ AuthorizationService authService = mock(AuthorizationService.class);
+ ConnectionManager manager = new ConnectionManager(authService,
ZeppelinConfiguration.load());
+ NotebookSocket socket = mock(NotebookSocket.class);
+
+ assertDoesNotThrow(() -> manager.removeUserConnection(null, socket));
+ }
+
+ @Test
+ void removeUserConnectionBeforeUserAssignment() {
+ AuthorizationService authService = mock(AuthorizationService.class);
+ ConnectionManager manager = new ConnectionManager(authService,
ZeppelinConfiguration.load());
+ NotebookSocket socket = mock(NotebookSocket.class);
+
+ assertDoesNotThrow(() -> manager.removeUserConnection("", socket));
+ assertTrue(manager.userSocketMap.isEmpty());
+ }
}