From f46143b7f23651d9e98a59aeb1f7a4df7889d8ce Mon Sep 17 00:00:00 2001 From: dengliming Date: Sun, 23 Aug 2026 00:01:28 +0800 Subject: [PATCH 01/18] feat: Add MongoDB Change Streams based EventStore adapter Support cross-node event distribution via MongoDB Change Streams, similar to Socket.IO's @socket.io/mongo-adapter. Both SINGLE_CHANNEL and MULTI_CHANNEL modes are supported. Requires a MongoDB replica set. --- netty-socketio-core/pom.xml | 5 + .../socketio/store/mongo/MongoEventStore.java | 281 ++++++++++++++++++ .../src/main/java/module-info.java | 5 + ...istributedMongoMultiChannelMemoryTest.java | 171 +++++++++++ ...stributedMongoSingleChannelMemoryTest.java | 171 +++++++++++ .../store/CustomizedMongoContainer.java | 94 ++++++ pom.xml | 7 + 7 files changed, 734 insertions(+) create mode 100644 netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java diff --git a/netty-socketio-core/pom.xml b/netty-socketio-core/pom.xml index 5fafbaf8..d760053f 100644 --- a/netty-socketio-core/pom.xml +++ b/netty-socketio-core/pom.xml @@ -143,6 +143,11 @@ + + org.mongodb + mongodb-driver-sync + provided + diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java new file mode 100644 index 00000000..b3ddabe3 --- /dev/null +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -0,0 +1,281 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.store.mongo; + +import java.util.Arrays; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bson.Document; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.client.ChangeStreamIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.changestream.ChangeStreamDocument; +import com.mongodb.client.model.changestream.FullDocument; + +import com.socketio4j.socketio.store.event.EventListener; +import com.socketio4j.socketio.store.event.EventMessage; +import com.socketio4j.socketio.store.event.EventStore; +import com.socketio4j.socketio.store.event.EventStoreMode; +import com.socketio4j.socketio.store.event.EventStoreType; +import com.socketio4j.socketio.store.event.EventType; +import com.socketio4j.socketio.store.event.PublishMode; + +/** + * MongoDB Change Streams based EventStore. + *

+ * Uses MongoDB Change Streams to watch for inserts on a collection and deliver + * events to subscribers. Each event type maps to its own collection (MULTI_CHANNEL) + * or all events go into one collection (SINGLE_CHANNEL). + *

+ * Requires a MongoDB replica set (standalone does not support change streams). + */ +public class MongoEventStore implements EventStore { + + private static final Logger log = + LoggerFactory.getLogger(MongoEventStore.class); + + private static final String DEFAULT_COLLECTION_PREFIX = "socketio_events_"; + + private static final ObjectMapper MAPPER; + + static { + MAPPER = new ObjectMapper(); + MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + private final MongoClient mongoClient; + private final MongoDatabase database; + private final Long nodeId; + private final EventStoreMode eventStoreMode; + private final String collectionPrefix; + + private final ConcurrentMap> watchers = + new ConcurrentHashMap<>(); + + private final ExecutorService watcherExecutor = + Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "socketio-mongo-watcher"); + t.setDaemon(true); + return t; + }); + + public MongoEventStore(@NotNull MongoClient mongoClient, + @NotNull String databaseName, + @Nullable EventStoreMode eventStoreMode, + @Nullable Long nodeId, + @Nullable String collectionPrefix) { + this.mongoClient = Objects.requireNonNull(mongoClient, "mongoClient"); + this.database = mongoClient.getDatabase( + Objects.requireNonNull(databaseName, "databaseName")); + + if (nodeId == null) { + nodeId = getNodeId(); + } + this.nodeId = nodeId; + + if (eventStoreMode == null) { + eventStoreMode = EventStoreMode.MULTI_CHANNEL; + } + this.eventStoreMode = eventStoreMode; + + this.collectionPrefix = collectionPrefix != null + ? collectionPrefix : DEFAULT_COLLECTION_PREFIX; + } + + @Override + public EventStoreMode getEventStoreMode() { + return eventStoreMode; + } + + @Override + public EventStoreType getEventStoreType() { + return EventStoreType.PUBSUB; + } + + @Override + public PublishMode getPublishMode() { + return PublishMode.UNRELIABLE; + } + + @Override + public void publish0(EventType type, EventMessage msg) { + msg.setNodeId(nodeId); + + try { + String collectionName = getCollectionName(type); + MongoCollection collection = database.getCollection(collectionName); + byte[] data = MAPPER.writeValueAsBytes(msg); + Document doc = new Document() + .append("nodeId", nodeId) + .append("eventType", type.name()) + .append("payload", new String(data, "UTF-8")); + collection.insertOne(doc); + } catch (Exception e) { + log.warn("Failed to publish event {}", type, e); + } + } + + @Override + public void subscribe0( + EventType type, + final EventListener listener, + Class clazz) { + + String collectionName = getCollectionName(type); + MongoCollection collection = database.getCollection(collectionName); + + WatcherHandle handle = new WatcherHandle(); + + watcherExecutor.submit(() -> { + while (!handle.stopped.get()) { + try { + ChangeStreamIterable changeStream = collection.watch() + .fullDocument(FullDocument.UPDATE_LOOKUP); + + for (ChangeStreamDocument change : changeStream) { + if (handle.stopped.get()) { + break; + } + if (change.getFullDocument() == null) { + continue; + } + try { + Document doc = change.getFullDocument(); + String payload = doc.getString("payload"); + if (payload == null) { + continue; + } + T event = MAPPER.readValue(payload, clazz); + if (!nodeId.equals(event.getNodeId())) { + listener.onMessage(event); + } + } catch (Exception e) { + log.warn("Failed to process change event on {}", collectionName, e); + } + } + } catch (Exception e) { + if (!handle.stopped.get()) { + log.warn("Change stream interrupted on {}, reconnecting...", + collectionName, e); + try { + Thread.sleep(1000); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + }); + + watchers.computeIfAbsent(type, k -> new ConcurrentLinkedQueue<>()) + .add(handle); + } + + @Override + public void unsubscribe0(EventType type) { + Queue handles = watchers.remove(type); + if (handles == null || handles.isEmpty()) { + return; + } + for (WatcherHandle handle : handles) { + handle.stopped.set(true); + } + } + + @Override + public void shutdown0() { + Arrays.stream(EventType.values()).forEach(this::unsubscribe); + watchers.clear(); + watcherExecutor.shutdown(); + try { + if (!watcherExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + watcherExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + watcherExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + private String getCollectionName(EventType type) { + if (EventStoreMode.SINGLE_CHANNEL.equals(eventStoreMode)) { + return collectionPrefix + EventType.ALL_SINGLE_CHANNEL.name(); + } + return collectionPrefix + type.name(); + } + + private static final class WatcherHandle { + final AtomicBoolean stopped = new AtomicBoolean(false); + } + + public static final class Builder { + + private final MongoClient mongoClient; + private final String databaseName; + + private Long nodeId; + private EventStoreMode eventStoreMode = EventStoreMode.MULTI_CHANNEL; + private String collectionPrefix; + + public Builder(@NotNull MongoClient mongoClient, @NotNull String databaseName) { + this.mongoClient = Objects.requireNonNull(mongoClient, "mongoClient"); + this.databaseName = Objects.requireNonNull(databaseName, "databaseName"); + } + + public Builder nodeId(long nodeId) { + this.nodeId = nodeId; + return this; + } + + public Builder eventStoreMode(@NotNull EventStoreMode mode) { + this.eventStoreMode = Objects.requireNonNull(mode, "eventStoreMode"); + return this; + } + + public Builder collectionPrefix(@NotNull String prefix) { + this.collectionPrefix = Objects.requireNonNull(prefix, "collectionPrefix"); + return this; + } + + public MongoEventStore build() { + return new MongoEventStore( + mongoClient, + databaseName, + eventStoreMode, + nodeId, + collectionPrefix + ); + } + } +} diff --git a/netty-socketio-core/src/main/java/module-info.java b/netty-socketio-core/src/main/java/module-info.java index 44d33dc6..00d32adc 100644 --- a/netty-socketio-core/src/main/java/module-info.java +++ b/netty-socketio-core/src/main/java/module-info.java @@ -50,6 +50,8 @@ exports com.socketio4j.socketio.store.redis_reliable; exports com.socketio4j.socketio.store.redis_stream; exports com.socketio4j.socketio.store.kafka; + exports com.socketio4j.socketio.store.mongo; + exports com.socketio4j.socketio.store.nats_pubsub; // ============================================================ // Reflective-only packages (not exported) @@ -74,6 +76,9 @@ requires static redisson; requires static io.nats.jnats; requires static kafka.clients; + requires static org.mongodb.bson; + requires static org.mongodb.driver.core; + requires static org.mongodb.driver.sync.client; // ============================================================ // Optional Netty native transports — only if available diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java new file mode 100644 index 00000000..f2be9898 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.integration; + +import java.net.ServerSocket; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; + +import com.mongodb.client.MongoClient; + +import com.socketio4j.socketio.Configuration; +import com.socketio4j.socketio.SocketIOServer; +import com.socketio4j.socketio.store.CustomizedMongoContainer; +import com.socketio4j.socketio.store.event.EventStoreMode; +import com.socketio4j.socketio.store.memory.MemoryStoreFactory; +import com.socketio4j.socketio.store.mongo.MongoEventStore; + + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class DistributedMongoMultiChannelMemoryTest extends DistributedCommonTest { + + private static final CustomizedMongoContainer MONGO_CONTAINER = + new CustomizedMongoContainer(); + + private static final String DB_NAME = "socketio_test"; + + private MongoClient mc1; + private MongoClient mc2; + + private int findAvailablePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + @BeforeAll + public void setup() throws Exception { + + MONGO_CONTAINER.start(); + + // ---------- NODE 1 ---------- + Configuration cfg1 = new Configuration(); + DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg1); + cfg1.setHostname("127.0.0.1"); + cfg1.setPort(findAvailablePort()); + + mc1 = MONGO_CONTAINER.createClient(); + cfg1.setStoreFactory( + new MemoryStoreFactory( + new MongoEventStore.Builder(mc1, DB_NAME) + .eventStoreMode(EventStoreMode.MULTI_CHANNEL) + .build() + ) + ); + + node1 = new SocketIOServer(cfg1); + node1.addEventListener("join-room", String.class, (c, room, ack) -> { + c.joinRoom(room); + c.sendEvent("join-ok", "OK"); + }); + node1.addEventListener("leave-room", String.class, (c, room, ack) -> { + c.leaveRoom(room); + c.sendEvent("leave-ok", "OK"); + }); + node1.addEventListener("get-my-rooms", String.class, (client, data, ackSender) -> { + if (ackSender.isAckRequested()) { + ackSender.sendAckData(client.getAllRooms()); + } + }); + node1.addConnectListener(client -> { + Map> params = + client.getHandshakeData().getUrlParams(); + List joinParams = params.get("join"); + if (joinParams == null || joinParams.isEmpty()) { + return; + } + Set rooms = joinParams.stream() + .flatMap(v -> Arrays.stream(v.split(","))) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + rooms.forEach(client::joinRoom); + }); + node1.start(); + port1 = cfg1.getPort(); + + // ---------- NODE 2 ---------- + Configuration cfg2 = new Configuration(); + DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg2); + cfg2.setHostname("127.0.0.1"); + cfg2.setPort(findAvailablePort()); + + mc2 = MONGO_CONTAINER.createClient(); + cfg2.setStoreFactory( + new MemoryStoreFactory( + new MongoEventStore.Builder(mc2, DB_NAME) + .eventStoreMode(EventStoreMode.MULTI_CHANNEL) + .build() + ) + ); + + node2 = new SocketIOServer(cfg2); + node2.addEventListener("join-room", String.class, (c, room, ack) -> { + c.joinRoom(room); + c.sendEvent("join-ok", "OK"); + }); + node2.addEventListener("leave-room", String.class, (c, room, ack) -> { + c.leaveRoom(room); + c.sendEvent("leave-ok", "OK"); + }); + node2.addEventListener("get-my-rooms", String.class, (client, data, ackSender) -> { + if (ackSender.isAckRequested()) { + ackSender.sendAckData(client.getAllRooms()); + } + }); + node2.addConnectListener(client -> { + Map> params = + client.getHandshakeData().getUrlParams(); + List joinParams = params.get("join"); + if (joinParams == null || joinParams.isEmpty()) { + return; + } + Set rooms = joinParams.stream() + .flatMap(v -> Arrays.stream(v.split(","))) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + rooms.forEach(client::joinRoom); + }); + node2.start(); + port2 = cfg2.getPort(); + } + + @AfterAll + public void stop() { + if (mc1 != null) { + mc1.close(); + } + if (mc2 != null) { + mc2.close(); + } + if (node1 != null) { + node1.stop(); + } + if (node2 != null) { + node2.stop(); + } + MONGO_CONTAINER.stop(); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java new file mode 100644 index 00000000..b4ca2f83 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.integration; + +import java.net.ServerSocket; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; + +import com.mongodb.client.MongoClient; + +import com.socketio4j.socketio.Configuration; +import com.socketio4j.socketio.SocketIOServer; +import com.socketio4j.socketio.store.CustomizedMongoContainer; +import com.socketio4j.socketio.store.event.EventStoreMode; +import com.socketio4j.socketio.store.memory.MemoryStoreFactory; +import com.socketio4j.socketio.store.mongo.MongoEventStore; + + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class DistributedMongoSingleChannelMemoryTest extends DistributedCommonTest { + + private static final CustomizedMongoContainer MONGO_CONTAINER = + new CustomizedMongoContainer(); + + private static final String DB_NAME = "socketio_test"; + + private MongoClient mc1; + private MongoClient mc2; + + private int findAvailablePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + @BeforeAll + public void setup() throws Exception { + + MONGO_CONTAINER.start(); + + // ---------- NODE 1 ---------- + Configuration cfg1 = new Configuration(); + DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg1); + cfg1.setHostname("127.0.0.1"); + cfg1.setPort(findAvailablePort()); + + mc1 = MONGO_CONTAINER.createClient(); + cfg1.setStoreFactory( + new MemoryStoreFactory( + new MongoEventStore.Builder(mc1, DB_NAME) + .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) + .build() + ) + ); + + node1 = new SocketIOServer(cfg1); + node1.addEventListener("join-room", String.class, (c, room, ack) -> { + c.joinRoom(room); + c.sendEvent("join-ok", "OK"); + }); + node1.addEventListener("leave-room", String.class, (c, room, ack) -> { + c.leaveRoom(room); + c.sendEvent("leave-ok", "OK"); + }); + node1.addEventListener("get-my-rooms", String.class, (client, data, ackSender) -> { + if (ackSender.isAckRequested()) { + ackSender.sendAckData(client.getAllRooms()); + } + }); + node1.addConnectListener(client -> { + Map> params = + client.getHandshakeData().getUrlParams(); + List joinParams = params.get("join"); + if (joinParams == null || joinParams.isEmpty()) { + return; + } + Set rooms = joinParams.stream() + .flatMap(v -> Arrays.stream(v.split(","))) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + rooms.forEach(client::joinRoom); + }); + node1.start(); + port1 = cfg1.getPort(); + + // ---------- NODE 2 ---------- + Configuration cfg2 = new Configuration(); + DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg2); + cfg2.setHostname("127.0.0.1"); + cfg2.setPort(findAvailablePort()); + + mc2 = MONGO_CONTAINER.createClient(); + cfg2.setStoreFactory( + new MemoryStoreFactory( + new MongoEventStore.Builder(mc2, DB_NAME) + .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) + .build() + ) + ); + + node2 = new SocketIOServer(cfg2); + node2.addEventListener("join-room", String.class, (c, room, ack) -> { + c.joinRoom(room); + c.sendEvent("join-ok", "OK"); + }); + node2.addEventListener("leave-room", String.class, (c, room, ack) -> { + c.leaveRoom(room); + c.sendEvent("leave-ok", "OK"); + }); + node2.addEventListener("get-my-rooms", String.class, (client, data, ackSender) -> { + if (ackSender.isAckRequested()) { + ackSender.sendAckData(client.getAllRooms()); + } + }); + node2.addConnectListener(client -> { + Map> params = + client.getHandshakeData().getUrlParams(); + List joinParams = params.get("join"); + if (joinParams == null || joinParams.isEmpty()) { + return; + } + Set rooms = joinParams.stream() + .flatMap(v -> Arrays.stream(v.split(","))) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + rooms.forEach(client::joinRoom); + }); + node2.start(); + port2 = cfg2.getPort(); + } + + @AfterAll + public void stop() { + if (mc1 != null) { + mc1.close(); + } + if (mc2 != null) { + mc2.close(); + } + if (node1 != null) { + node1.stop(); + } + if (node2 != null) { + node2.stop(); + } + MONGO_CONTAINER.stop(); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java new file mode 100644 index 00000000..5db6df37 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.store; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; + +/** + * CI-safe customized MongoDB container for socketio4j tests. + *

+ * Uses a single-node replica set so that change streams are available. + */ +public class CustomizedMongoContainer extends GenericContainer { + + private static final Logger log = + LoggerFactory.getLogger(CustomizedMongoContainer.class); + + private static final int MONGO_PORT = 27017; + + public CustomizedMongoContainer() { + super(DockerImageName.parse("mongo:7.0")); + + withExposedPorts(MONGO_PORT); + withCommand("--replSet", "rs0"); + withReuse(false); + withStartupAttempts(3); + withStartupTimeout(Duration.ofMinutes(2)); + } + + @Override + public void start() { + super.start(); + initReplicaSet(); + log.info("MongoDB replica set ready at {}", getConnectionString()); + } + + private void initReplicaSet() { + try { + ExecResult result = execInContainer( + "mongosh", "--eval", + "rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:" + MONGO_PORT + "'}]})" + ); + log.debug("rs.initiate output: {}", result.getStdout()); + + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30); + while (System.currentTimeMillis() < deadline) { + ExecResult status = execInContainer( + "mongosh", "--eval", "rs.status().ok" + ); + String out = status.getStdout().trim(); + if (out.contains("1")) { + return; + } + Thread.sleep(500); + } + throw new RuntimeException("MongoDB replica set not ready after 30s"); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Failed to initialize MongoDB replica set", e); + } + } + + public String getConnectionString() { + return "mongodb://" + getHost() + ":" + getMappedPort(MONGO_PORT) + + "/?directConnection=true"; + } + + public MongoClient createClient() { + return MongoClients.create(getConnectionString()); + } +} diff --git a/pom.xml b/pom.xml index 1c417d9c..45355253 100644 --- a/pom.xml +++ b/pom.xml @@ -87,6 +87,7 @@ 4.3.0 3.20.0 2.25.3 + 5.5.0 1.17.0 1.6.0 1.10.3 @@ -351,6 +352,12 @@ ${kafka.version} provided + + org.mongodb + mongodb-driver-sync + ${mongodb.version} + provided + From aee5b06863849ce605d425dd5134376977e1d16f Mon Sep 17 00:00:00 2001 From: dengliming Date: Sun, 23 Aug 2026 01:40:25 +0800 Subject: [PATCH 02/18] Fix review comments on MongoDB adapter --- .../socketio/store/mongo/MongoEventStore.java | 165 ++++++++++++++---- .../src/main/java/module-info.java | 1 - ...istributedMongoMultiChannelMemoryTest.java | 12 +- ...stributedMongoSingleChannelMemoryTest.java | 12 +- .../store/CustomizedMongoContainer.java | 2 +- 5 files changed, 144 insertions(+), 48 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index b3ddabe3..869798cf 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -16,7 +16,9 @@ */ package com.socketio4j.socketio.store.mongo; +import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Date; import java.util.Objects; import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; @@ -35,12 +37,14 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.mongodb.client.ChangeStreamIterable; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoCursor; import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; import com.mongodb.client.model.changestream.ChangeStreamDocument; -import com.mongodb.client.model.changestream.FullDocument; +import com.mongodb.client.model.changestream.OperationType; import com.socketio4j.socketio.store.event.EventListener; import com.socketio4j.socketio.store.event.EventMessage; @@ -57,6 +61,10 @@ * events to subscribers. Each event type maps to its own collection (MULTI_CHANNEL) * or all events go into one collection (SINGLE_CHANNEL). *

+ * A TTL index is created on each collection to automatically expire documents + * after a configurable retention period (default 60 seconds), preventing + * unbounded data growth. + *

* Requires a MongoDB replica set (standalone does not support change streams). */ public class MongoEventStore implements EventStore { @@ -65,6 +73,8 @@ public class MongoEventStore implements EventStore { LoggerFactory.getLogger(MongoEventStore.class); private static final String DEFAULT_COLLECTION_PREFIX = "socketio_events_"; + private static final long DEFAULT_TTL_SECONDS = 60; + private static final long POLL_INTERVAL_MS = 100; private static final ObjectMapper MAPPER; @@ -73,11 +83,11 @@ public class MongoEventStore implements EventStore { MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } - private final MongoClient mongoClient; private final MongoDatabase database; private final Long nodeId; private final EventStoreMode eventStoreMode; private final String collectionPrefix; + private final long ttlSeconds; private final ConcurrentMap> watchers = new ConcurrentHashMap<>(); @@ -89,27 +99,30 @@ public class MongoEventStore implements EventStore { return t; }); + /** + * Creates a new MongoEventStore. + * + * @param mongoClient shared MongoDB client + * @param databaseName database to use for event collections + * @param eventStoreMode SINGLE_CHANNEL or MULTI_CHANNEL (defaults to MULTI_CHANNEL) + * @param nodeId node identifier used to ignore self-published events + * @param collectionPrefix prefix for collection names (defaults to "socketio_events_") + * @param ttlSeconds TTL in seconds for automatic document expiry (defaults to 60) + */ public MongoEventStore(@NotNull MongoClient mongoClient, @NotNull String databaseName, @Nullable EventStoreMode eventStoreMode, @Nullable Long nodeId, - @Nullable String collectionPrefix) { - this.mongoClient = Objects.requireNonNull(mongoClient, "mongoClient"); + @Nullable String collectionPrefix, + long ttlSeconds) { + Objects.requireNonNull(mongoClient, "mongoClient"); this.database = mongoClient.getDatabase( Objects.requireNonNull(databaseName, "databaseName")); - if (nodeId == null) { - nodeId = getNodeId(); - } - this.nodeId = nodeId; - - if (eventStoreMode == null) { - eventStoreMode = EventStoreMode.MULTI_CHANNEL; - } - this.eventStoreMode = eventStoreMode; - - this.collectionPrefix = collectionPrefix != null - ? collectionPrefix : DEFAULT_COLLECTION_PREFIX; + this.nodeId = nodeId != null ? nodeId : getNodeId(); + this.eventStoreMode = eventStoreMode != null ? eventStoreMode : EventStoreMode.MULTI_CHANNEL; + this.collectionPrefix = collectionPrefix != null ? collectionPrefix : DEFAULT_COLLECTION_PREFIX; + this.ttlSeconds = ttlSeconds > 0 ? ttlSeconds : DEFAULT_TTL_SECONDS; } @Override @@ -131,18 +144,20 @@ public PublishMode getPublishMode() { public void publish0(EventType type, EventMessage msg) { msg.setNodeId(nodeId); + String collectionName = getCollectionName(type); + MongoCollection collection = database.getCollection(collectionName); + byte[] data; try { - String collectionName = getCollectionName(type); - MongoCollection collection = database.getCollection(collectionName); - byte[] data = MAPPER.writeValueAsBytes(msg); - Document doc = new Document() - .append("nodeId", nodeId) - .append("eventType", type.name()) - .append("payload", new String(data, "UTF-8")); - collection.insertOne(doc); + data = MAPPER.writeValueAsBytes(msg); } catch (Exception e) { - log.warn("Failed to publish event {}", type, e); + throw new RuntimeException("Failed to serialize EventMessage", e); } + Document doc = new Document() + .append("nodeId", nodeId) + .append("eventType", type.name()) + .append("createdAt", new Date()) + .append("payload", new String(data, StandardCharsets.UTF_8)); + collection.insertOne(doc); } @Override @@ -154,23 +169,43 @@ public void subscribe0( String collectionName = getCollectionName(type); MongoCollection collection = database.getCollection(collectionName); + ensureTtlIndex(collection); + WatcherHandle handle = new WatcherHandle(); watcherExecutor.submit(() -> { while (!handle.stopped.get()) { + MongoCursor> cursor = null; try { - ChangeStreamIterable changeStream = collection.watch() - .fullDocument(FullDocument.UPDATE_LOOKUP); + cursor = collection.watch().cursor(); + handle.setCursor(cursor); - for (ChangeStreamDocument change : changeStream) { - if (handle.stopped.get()) { - break; + while (!handle.stopped.get()) { + ChangeStreamDocument change = cursor.tryNext(); + if (change == null) { + Thread.sleep(POLL_INTERVAL_MS); + continue; } + + if (change.getOperationType() != OperationType.INSERT) { + continue; + } + if (change.getFullDocument() == null) { continue; } + try { Document doc = change.getFullDocument(); + + if (EventStoreMode.MULTI_CHANNEL.equals(eventStoreMode)) { + String eventTypeName = doc.getString("eventType"); + if (eventTypeName != null + && !type.name().equals(eventTypeName)) { + continue; + } + } + String payload = doc.getString("payload"); if (payload == null) { continue; @@ -183,6 +218,9 @@ public void subscribe0( log.warn("Failed to process change event on {}", collectionName, e); } } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; } catch (Exception e) { if (!handle.stopped.get()) { log.warn("Change stream interrupted on {}, reconnecting...", @@ -194,6 +232,9 @@ public void subscribe0( break; } } + } finally { + closeCursorQuietly(cursor); + handle.setCursor(null); } } }); @@ -209,7 +250,7 @@ public void unsubscribe0(EventType type) { return; } for (WatcherHandle handle : handles) { - handle.stopped.set(true); + handle.stop(); } } @@ -228,6 +269,18 @@ public void shutdown0() { } } + private void ensureTtlIndex(MongoCollection collection) { + try { + collection.createIndex( + Indexes.ascending("createdAt"), + new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS) + ); + } catch (Exception e) { + log.debug("TTL index already exists or creation skipped on {}: {}", + collection.getNamespace(), e.getMessage()); + } + } + private String getCollectionName(EventType type) { if (EventStoreMode.SINGLE_CHANNEL.equals(eventStoreMode)) { return collectionPrefix + EventType.ALL_SINGLE_CHANNEL.name(); @@ -235,10 +288,33 @@ private String getCollectionName(EventType type) { return collectionPrefix + type.name(); } + private static void closeCursorQuietly(MongoCursor cursor) { + if (cursor != null) { + try { + cursor.close(); + } catch (Exception e) { + // ignore + } + } + } + private static final class WatcherHandle { final AtomicBoolean stopped = new AtomicBoolean(false); + private volatile MongoCursor cursor; + + void setCursor(MongoCursor cursor) { + this.cursor = cursor; + } + + void stop() { + stopped.set(true); + closeCursorQuietly(cursor); + } } + /** + * Builder for {@link MongoEventStore}. + */ public static final class Builder { private final MongoClient mongoClient; @@ -247,7 +323,14 @@ public static final class Builder { private Long nodeId; private EventStoreMode eventStoreMode = EventStoreMode.MULTI_CHANNEL; private String collectionPrefix; - + private long ttlSeconds = DEFAULT_TTL_SECONDS; + + /** + * Creates a new builder. + * + * @param mongoClient shared MongoDB client (must connect to a replica set) + * @param databaseName database to use for event collections + */ public Builder(@NotNull MongoClient mongoClient, @NotNull String databaseName) { this.mongoClient = Objects.requireNonNull(mongoClient, "mongoClient"); this.databaseName = Objects.requireNonNull(databaseName, "databaseName"); @@ -268,13 +351,27 @@ public Builder collectionPrefix(@NotNull String prefix) { return this; } + /** + * Sets the TTL in seconds for automatic document expiry. + * Documents older than this are automatically removed by MongoDB. + * Default is 60 seconds. + * + * @param ttlSeconds retention period in seconds + * @return this builder + */ + public Builder ttlSeconds(long ttlSeconds) { + this.ttlSeconds = ttlSeconds; + return this; + } + public MongoEventStore build() { return new MongoEventStore( mongoClient, databaseName, eventStoreMode, nodeId, - collectionPrefix + collectionPrefix, + ttlSeconds ); } } diff --git a/netty-socketio-core/src/main/java/module-info.java b/netty-socketio-core/src/main/java/module-info.java index 00d32adc..5c93df18 100644 --- a/netty-socketio-core/src/main/java/module-info.java +++ b/netty-socketio-core/src/main/java/module-info.java @@ -51,7 +51,6 @@ exports com.socketio4j.socketio.store.redis_stream; exports com.socketio4j.socketio.store.kafka; exports com.socketio4j.socketio.store.mongo; - exports com.socketio4j.socketio.store.nats_pubsub; // ============================================================ // Reflective-only packages (not exported) diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java index f2be9898..034f05c6 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java @@ -154,18 +154,18 @@ public void setup() throws Exception { @AfterAll public void stop() { - if (mc1 != null) { - mc1.close(); - } - if (mc2 != null) { - mc2.close(); - } if (node1 != null) { node1.stop(); } if (node2 != null) { node2.stop(); } + if (mc1 != null) { + mc1.close(); + } + if (mc2 != null) { + mc2.close(); + } MONGO_CONTAINER.stop(); } } diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java index b4ca2f83..3b93b2ae 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java @@ -154,18 +154,18 @@ public void setup() throws Exception { @AfterAll public void stop() { - if (mc1 != null) { - mc1.close(); - } - if (mc2 != null) { - mc2.close(); - } if (node1 != null) { node1.stop(); } if (node2 != null) { node2.stop(); } + if (mc1 != null) { + mc1.close(); + } + if (mc2 != null) { + mc2.close(); + } MONGO_CONTAINER.stop(); } } diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java index 5db6df37..e64b21e2 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java @@ -85,7 +85,7 @@ private void initReplicaSet() { public String getConnectionString() { return "mongodb://" + getHost() + ":" + getMappedPort(MONGO_PORT) - + "/?directConnection=true"; + + "/?replicaSet=rs0&directConnection=true"; } public MongoClient createClient() { From 35b1e87580e62249f83704097fdb534ab322ac35 Mon Sep 17 00:00:00 2001 From: dengliming Date: Sun, 23 Aug 2026 07:01:36 +0800 Subject: [PATCH 03/18] Fix review comments on MongoDB adapter --- .../socketio/store/mongo/MongoEventStore.java | 67 ++++++++++++++----- .../src/main/java/module-info.java | 1 + 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 869798cf..4956b536 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -24,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -74,7 +75,7 @@ public class MongoEventStore implements EventStore { private static final String DEFAULT_COLLECTION_PREFIX = "socketio_events_"; private static final long DEFAULT_TTL_SECONDS = 60; - private static final long POLL_INTERVAL_MS = 100; + private static final long WATCH_STARTUP_TIMEOUT_SECONDS = 10; private static final ObjectMapper MAPPER; @@ -172,21 +173,24 @@ public void subscribe0( ensureTtlIndex(collection); WatcherHandle handle = new WatcherHandle(); + // Opening the change stream is async, but events published before the + // cursor exists are lost. Let subscribe0 block until it is open. + CountDownLatch opened = new CountDownLatch(1); watcherExecutor.submit(() -> { while (!handle.stopped.get()) { - MongoCursor> cursor = null; - try { - cursor = collection.watch().cursor(); + try (MongoCursor> cursor = + collection.watch().cursor()) { handle.setCursor(cursor); + opened.countDown(); - while (!handle.stopped.get()) { - ChangeStreamDocument change = cursor.tryNext(); - if (change == null) { - Thread.sleep(POLL_INTERVAL_MS); - continue; + while (!handle.stopped.get() && cursor.hasNext()) { + if (handle.stopped.get()) { + break; } + ChangeStreamDocument change = cursor.next(); + if (change.getOperationType() != OperationType.INSERT) { continue; } @@ -218,9 +222,6 @@ public void subscribe0( log.warn("Failed to process change event on {}", collectionName, e); } } - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - break; } catch (Exception e) { if (!handle.stopped.get()) { log.warn("Change stream interrupted on {}, reconnecting...", @@ -233,7 +234,8 @@ public void subscribe0( } } } finally { - closeCursorQuietly(cursor); + // Never leave subscribe0 blocked if the stream failed to open. + opened.countDown(); handle.setCursor(null); } } @@ -241,6 +243,16 @@ public void subscribe0( watchers.computeIfAbsent(type, k -> new ConcurrentLinkedQueue<>()) .add(handle); + + try { + if (!opened.await(WATCH_STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + log.warn("Change stream on {} not established within {}s; " + + "early events may be missed", + collectionName, WATCH_STARTUP_TIMEOUT_SECONDS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } @Override @@ -269,6 +281,15 @@ public void shutdown0() { } } + /** + * Creates the TTL index used to expire published events. + *

+ * {@code createIndex} is idempotent for an identical index, so a failure here + * is a real problem — an options conflict with an existing {@code createdAt} + * index (a changed {@code ttlSeconds}) or missing privileges. Events then never + * expire and the collection grows without bound, so log it at warn rather than + * hiding it; subscription itself still works, so this must not abort startup. + */ private void ensureTtlIndex(MongoCollection collection) { try { collection.createIndex( @@ -276,8 +297,8 @@ private void ensureTtlIndex(MongoCollection collection) { new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS) ); } catch (Exception e) { - log.debug("TTL index already exists or creation skipped on {}: {}", - collection.getNamespace(), e.getMessage()); + log.warn("Failed to create TTL index on {}; published events may never expire", + collection.getNamespace(), e); } } @@ -300,15 +321,25 @@ private static void closeCursorQuietly(MongoCursor cursor) { private static final class WatcherHandle { final AtomicBoolean stopped = new AtomicBoolean(false); - private volatile MongoCursor cursor; + private MongoCursor cursor; - void setCursor(MongoCursor cursor) { + /** + * Publishes the cursor so {@link #stop()} can close it and unblock + * {@code hasNext()}. If stop already happened, closes it right away — + * otherwise the watcher would block on a cursor nobody can close. + */ + synchronized void setCursor(MongoCursor cursor) { + if (cursor != null && stopped.get()) { + closeCursorQuietly(cursor); + return; + } this.cursor = cursor; } - void stop() { + synchronized void stop() { stopped.set(true); closeCursorQuietly(cursor); + cursor = null; } } diff --git a/netty-socketio-core/src/main/java/module-info.java b/netty-socketio-core/src/main/java/module-info.java index 5c93df18..29b47f6c 100644 --- a/netty-socketio-core/src/main/java/module-info.java +++ b/netty-socketio-core/src/main/java/module-info.java @@ -50,6 +50,7 @@ exports com.socketio4j.socketio.store.redis_reliable; exports com.socketio4j.socketio.store.redis_stream; exports com.socketio4j.socketio.store.kafka; + exports com.socketio4j.socketio.store.nats_pubsub; exports com.socketio4j.socketio.store.mongo; // ============================================================ From 4e300c35ff127c2445d22572ba61e29c171d9a3a Mon Sep 17 00:00:00 2001 From: dengliming Date: Sat, 29 Aug 2026 00:25:12 +0800 Subject: [PATCH 04/18] Fix review comments on MongoDB adapter - Reconcile the createdAt TTL index via collMod when ttlSeconds changes, since createIndex leaves an existing index untouched. - Register the watcher handle before submitting its task so a concurrent unsubscribe cannot leave an active unregistered watcher. - Reject subscriptions whose EventType does not match the configured mode, consistent with RedisStreamEventStore. - Make the test replica set readiness check exact instead of a substring match. --- .../socketio/store/mongo/MongoEventStore.java | 108 +++++++++++++++--- .../store/CustomizedMongoContainer.java | 8 +- 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 4956b536..434c35a3 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -167,6 +167,8 @@ public void subscribe0( final EventListener listener, Class clazz) { + validateSubscribe(type); + String collectionName = getCollectionName(type); MongoCollection collection = database.getCollection(collectionName); @@ -177,7 +179,13 @@ public void subscribe0( // cursor exists are lost. Let subscribe0 block until it is open. CountDownLatch opened = new CountDownLatch(1); - watcherExecutor.submit(() -> { + // Register before submitting: a concurrent unsubscribe0 that ran in between + // would find no handle and leave the watcher delivering events afterwards. + Queue handles = + watchers.computeIfAbsent(type, k -> new ConcurrentLinkedQueue<>()); + handles.add(handle); + + Runnable watcher = () -> { while (!handle.stopped.get()) { try (MongoCursor> cursor = collection.watch().cursor()) { @@ -239,10 +247,16 @@ public void subscribe0( handle.setCursor(null); } } - }); + }; - watchers.computeIfAbsent(type, k -> new ConcurrentLinkedQueue<>()) - .add(handle); + try { + watcherExecutor.submit(watcher); + } catch (RuntimeException e) { + // Nothing will ever run for this handle, so do not leave it registered. + handles.remove(handle); + handle.stop(); + throw e; + } try { if (!opened.await(WATCH_STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { @@ -282,23 +296,85 @@ public void shutdown0() { } /** - * Creates the TTL index used to expire published events. + * Creates or reconciles the TTL index used to expire published events. + *

+ * {@code createIndex} is idempotent only for an identical index: against an + * existing {@code createdAt} index with a different {@code expireAfterSeconds} + * it fails with an index-options conflict and leaves the old retention period + * in place. So an existing index is inspected first and, when its TTL differs, + * updated in place with {@code collMod}. *

- * {@code createIndex} is idempotent for an identical index, so a failure here - * is a real problem — an options conflict with an existing {@code createdAt} - * index (a changed {@code ttlSeconds}) or missing privileges. Events then never - * expire and the collection grows without bound, so log it at warn rather than - * hiding it; subscription itself still works, so this must not abort startup. + * A failure here (missing privileges, an unsupported server) means events may + * never expire and the collection grows without bound, so log it at warn rather + * than hiding it; subscription itself still works, so this must not abort startup. */ private void ensureTtlIndex(MongoCollection collection) { try { - collection.createIndex( - Indexes.ascending("createdAt"), - new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS) - ); + Document existing = findCreatedAtIndex(collection); + if (existing == null) { + collection.createIndex( + Indexes.ascending("createdAt"), + new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS) + ); + return; + } + + Number current = existing.get("expireAfterSeconds", Number.class); + if (current != null && current.longValue() == ttlSeconds) { + return; + } + + // createIndex would only raise an options conflict here, so change the + // existing index in place instead of recreating it. + database.runCommand(new Document("collMod", collection.getNamespace().getCollectionName()) + .append("index", new Document("keyPattern", new Document("createdAt", 1)) + .append("expireAfterSeconds", ttlSeconds))); + log.info("Updated TTL index on {} from {} to {} seconds", + collection.getNamespace(), current, ttlSeconds); } catch (Exception e) { - log.warn("Failed to create TTL index on {}; published events may never expire", - collection.getNamespace(), e); + log.warn("Failed to apply TTL index of {}s on {}; published events may never " + + "expire or may use a stale retention period", + ttlSeconds, collection.getNamespace(), e); + } + } + + /** + * Returns the existing index on exactly {@code {createdAt: 1}}, or {@code null} + * if the collection has none. Compound or descending indexes on {@code createdAt} + * are not the one created here, so they are ignored. + */ + private static Document findCreatedAtIndex(MongoCollection collection) { + for (Document index : collection.listIndexes()) { + Document key = index.get("key", Document.class); + if (key == null || key.size() != 1) { + continue; + } + Object direction = key.get("createdAt"); + if (direction instanceof Number && ((Number) direction).intValue() == 1) { + return index; + } + } + return null; + } + + /** + * Rejects a subscription whose {@link EventType} does not match the configured mode. + *

+ * In SINGLE_CHANNEL mode every event type shares one collection, so only the + * {@code ALL_SINGLE_CHANNEL} subscription — the one {@code BaseStoreFactory} opens — + * is meaningful; a per-type subscription would silently watch the shared collection + * and receive unrelated event types. Mirrors {@code RedisStreamEventStore}. + */ + private void validateSubscribe(EventType type) { + if (EventStoreMode.SINGLE_CHANNEL.equals(eventStoreMode) + && type != EventType.ALL_SINGLE_CHANNEL) { + throw new UnsupportedOperationException( + "Only ALL_SINGLE_CHANNEL allowed in SINGLE_CHANNEL mode"); + } + if (EventStoreMode.MULTI_CHANNEL.equals(eventStoreMode) + && type == EventType.ALL_SINGLE_CHANNEL) { + throw new UnsupportedOperationException( + "ALL_SINGLE_CHANNEL not allowed in MULTI_CHANNEL mode"); } } diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java index e64b21e2..210a2ade 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java @@ -66,11 +66,15 @@ private void initReplicaSet() { long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30); while (System.currentTimeMillis() < deadline) { + // --quiet keeps banners out of stdout, and rs.status() throws until the + // set is initiated, so swallow that and compare the result exactly: + // a substring match would accept any output containing a "1". ExecResult status = execInContainer( - "mongosh", "--eval", "rs.status().ok" + "mongosh", "--quiet", "--eval", + "try { rs.status().ok } catch (e) { 0 }" ); String out = status.getStdout().trim(); - if (out.contains("1")) { + if ("1".equals(out)) { return; } Thread.sleep(500); From 851d7a501f493415076652c8c3c15c0dfd48b129 Mon Sep 17 00:00:00 2001 From: dengliming Date: Sat, 29 Aug 2026 00:42:24 +0800 Subject: [PATCH 05/18] Fix review comments on MongoDB adapter - Register the watcher handle in a single atomic watchers.compute, so an unsubscribe dropping the queue in between cannot leave it unregistered. - Wait for the test replica set member to become writable primary rather than for rs.status().ok, which is set before the election completes. --- .../socketio/store/mongo/MongoEventStore.java | 27 ++++++++++++++----- .../store/CustomizedMongoContainer.java | 12 +++++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 434c35a3..9b2c2cc5 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -179,11 +179,15 @@ public void subscribe0( // cursor exists are lost. Let subscribe0 block until it is open. CountDownLatch opened = new CountDownLatch(1); - // Register before submitting: a concurrent unsubscribe0 that ran in between - // would find no handle and leave the watcher delivering events afterwards. - Queue handles = - watchers.computeIfAbsent(type, k -> new ConcurrentLinkedQueue<>()); - handles.add(handle); + // Register before submitting, and in a single atomic map operation: with a + // separate computeIfAbsent + add, an unsubscribe0 dropping the queue in + // between would leave the watcher running but unregistered, still delivering + // events after unsubscribe. + watchers.compute(type, (k, queue) -> { + Queue q = queue != null ? queue : new ConcurrentLinkedQueue<>(); + q.add(handle); + return q; + }); Runnable watcher = () -> { while (!handle.stopped.get()) { @@ -253,7 +257,7 @@ public void subscribe0( watcherExecutor.submit(watcher); } catch (RuntimeException e) { // Nothing will ever run for this handle, so do not leave it registered. - handles.remove(handle); + unregister(type, handle); handle.stop(); throw e; } @@ -269,6 +273,17 @@ public void subscribe0( } } + /** + * Removes one handle from its type's queue atomically, so it cannot race with + * the {@code compute} in {@link #subscribe0} or the removal in {@link #unsubscribe0}. + */ + private void unregister(EventType type, WatcherHandle handle) { + watchers.computeIfPresent(type, (k, queue) -> { + queue.remove(handle); + return queue.isEmpty() ? null : queue; + }); + } + @Override public void unsubscribe0(EventType type) { Queue handles = watchers.remove(type); diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java index 210a2ade..3d20648f 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java @@ -66,15 +66,17 @@ private void initReplicaSet() { long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(30); while (System.currentTimeMillis() < deadline) { - // --quiet keeps banners out of stdout, and rs.status() throws until the - // set is initiated, so swallow that and compare the result exactly: - // a substring match would accept any output containing a "1". + // rs.initiate() only starts an election, so rs.status().ok == 1 does not + // yet mean this member can accept writes — wait for it to be PRIMARY. + // --quiet keeps banners out of stdout, hello() throws until the set is + // initiated (swallowed below), and the result is compared exactly: a + // substring match would accept any output that merely contains the value. ExecResult status = execInContainer( "mongosh", "--quiet", "--eval", - "try { rs.status().ok } catch (e) { 0 }" + "try { db.hello().isWritablePrimary } catch (e) { false }" ); String out = status.getStdout().trim(); - if ("1".equals(out)) { + if ("true".equals(out)) { return; } Thread.sleep(500); From 109563d692a17a07c0aee060aede4b9d073c2070 Mon Sep 17 00:00:00 2001 From: dengliming Date: Sat, 29 Aug 2026 00:48:08 +0800 Subject: [PATCH 06/18] Fix review comments on MongoDB adapter Create the TTL index first and reconcile on conflict, instead of enumerating indexes first. createIndex also creates a missing collection, so the TTL index is always established, and an index-options conflict (error 85) is the only case that needs collMod. --- .../socketio/store/mongo/MongoEventStore.java | 75 ++++++++----------- 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 9b2c2cc5..a34f045b 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -38,6 +38,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.MongoCommandException; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoCursor; @@ -77,6 +78,9 @@ public class MongoEventStore implements EventStore { private static final long DEFAULT_TTL_SECONDS = 60; private static final long WATCH_STARTUP_TIMEOUT_SECONDS = 10; + /** MongoDB error code raised when an index exists with the same key but different options. */ + private static final int INDEX_OPTIONS_CONFLICT = 85; + private static final ObjectMapper MAPPER; static { @@ -313,63 +317,46 @@ public void shutdown0() { /** * Creates or reconciles the TTL index used to expire published events. *

- * {@code createIndex} is idempotent only for an identical index: against an - * existing {@code createdAt} index with a different {@code expireAfterSeconds} - * it fails with an index-options conflict and leaves the old retention period - * in place. So an existing index is inspected first and, when its TTL differs, - * updated in place with {@code collMod}. + * {@code createIndex} creates the collection when it does not exist yet and is a + * no-op for an identical index, but it never updates an existing {@code createdAt} + * index whose {@code expireAfterSeconds} differs — it fails with an index-options + * conflict and leaves the old retention period in place. That conflict is caught + * here and the TTL is changed in place with {@code collMod}. *

- * A failure here (missing privileges, an unsupported server) means events may + * Any other failure (missing privileges, an unsupported server) means events may * never expire and the collection grows without bound, so log it at warn rather * than hiding it; subscription itself still works, so this must not abort startup. */ private void ensureTtlIndex(MongoCollection collection) { try { - Document existing = findCreatedAtIndex(collection); - if (existing == null) { - collection.createIndex( - Indexes.ascending("createdAt"), - new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS) - ); + collection.createIndex( + Indexes.ascending("createdAt"), + new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS) + ); + } catch (MongoCommandException e) { + if (e.getErrorCode() != INDEX_OPTIONS_CONFLICT) { + logTtlIndexFailure(collection, e); return; } - - Number current = existing.get("expireAfterSeconds", Number.class); - if (current != null && current.longValue() == ttlSeconds) { - return; + try { + database.runCommand( + new Document("collMod", collection.getNamespace().getCollectionName()) + .append("index", new Document("keyPattern", new Document("createdAt", 1)) + .append("expireAfterSeconds", ttlSeconds))); + log.info("Updated TTL index on {} to {} seconds", + collection.getNamespace(), ttlSeconds); + } catch (Exception ce) { + logTtlIndexFailure(collection, ce); } - - // createIndex would only raise an options conflict here, so change the - // existing index in place instead of recreating it. - database.runCommand(new Document("collMod", collection.getNamespace().getCollectionName()) - .append("index", new Document("keyPattern", new Document("createdAt", 1)) - .append("expireAfterSeconds", ttlSeconds))); - log.info("Updated TTL index on {} from {} to {} seconds", - collection.getNamespace(), current, ttlSeconds); } catch (Exception e) { - log.warn("Failed to apply TTL index of {}s on {}; published events may never " - + "expire or may use a stale retention period", - ttlSeconds, collection.getNamespace(), e); + logTtlIndexFailure(collection, e); } } - /** - * Returns the existing index on exactly {@code {createdAt: 1}}, or {@code null} - * if the collection has none. Compound or descending indexes on {@code createdAt} - * are not the one created here, so they are ignored. - */ - private static Document findCreatedAtIndex(MongoCollection collection) { - for (Document index : collection.listIndexes()) { - Document key = index.get("key", Document.class); - if (key == null || key.size() != 1) { - continue; - } - Object direction = key.get("createdAt"); - if (direction instanceof Number && ((Number) direction).intValue() == 1) { - return index; - } - } - return null; + private void logTtlIndexFailure(MongoCollection collection, Exception e) { + log.warn("Failed to apply TTL index of {}s on {}; published events may never " + + "expire or may use a stale retention period", + ttlSeconds, collection.getNamespace(), e); } /** From 74bac8dae4629f803adfdb75fdefbe1056620695 Mon Sep 17 00:00:00 2001 From: dengliming Date: Sat, 29 Aug 2026 16:16:23 +0800 Subject: [PATCH 07/18] Align Mongo integration tests with the reorganized cluster test layout main moved the distributed tests into integration.cluster and the test containers into store.container, so the two Mongo tests no longer compiled after the merge: DistributedCommonTest and DistributedClusterIntegrationSupport are not in integration anymore. Consolidate both into DistributedMongoClusterTest with one shared container and nested single/multi channel variants, matching the Kafka and NATS tests, and move CustomizedMongoContainer into store.container. --- ...istributedMongoMultiChannelMemoryTest.java | 171 ------------------ ...stributedMongoSingleChannelMemoryTest.java | 171 ------------------ .../cluster/DistributedMongoClusterTest.java | 153 ++++++++++++++++ .../CustomizedMongoContainer.java | 2 +- 4 files changed, 154 insertions(+), 343 deletions(-) delete mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java delete mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java rename netty-socketio-core/src/test/java/com/socketio4j/socketio/store/{ => container}/CustomizedMongoContainer.java (98%) diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java deleted file mode 100644 index 034f05c6..00000000 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoMultiChannelMemoryTest.java +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Copyright (c) 2025 The Socketio4j Project - * Parent project : Copyright (c) 2012-2025 Nikita Koksharov - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.socketio4j.socketio.integration; - -import java.net.ServerSocket; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.TestInstance; - -import com.mongodb.client.MongoClient; - -import com.socketio4j.socketio.Configuration; -import com.socketio4j.socketio.SocketIOServer; -import com.socketio4j.socketio.store.CustomizedMongoContainer; -import com.socketio4j.socketio.store.event.EventStoreMode; -import com.socketio4j.socketio.store.memory.MemoryStoreFactory; -import com.socketio4j.socketio.store.mongo.MongoEventStore; - - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class DistributedMongoMultiChannelMemoryTest extends DistributedCommonTest { - - private static final CustomizedMongoContainer MONGO_CONTAINER = - new CustomizedMongoContainer(); - - private static final String DB_NAME = "socketio_test"; - - private MongoClient mc1; - private MongoClient mc2; - - private int findAvailablePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } - - @BeforeAll - public void setup() throws Exception { - - MONGO_CONTAINER.start(); - - // ---------- NODE 1 ---------- - Configuration cfg1 = new Configuration(); - DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg1); - cfg1.setHostname("127.0.0.1"); - cfg1.setPort(findAvailablePort()); - - mc1 = MONGO_CONTAINER.createClient(); - cfg1.setStoreFactory( - new MemoryStoreFactory( - new MongoEventStore.Builder(mc1, DB_NAME) - .eventStoreMode(EventStoreMode.MULTI_CHANNEL) - .build() - ) - ); - - node1 = new SocketIOServer(cfg1); - node1.addEventListener("join-room", String.class, (c, room, ack) -> { - c.joinRoom(room); - c.sendEvent("join-ok", "OK"); - }); - node1.addEventListener("leave-room", String.class, (c, room, ack) -> { - c.leaveRoom(room); - c.sendEvent("leave-ok", "OK"); - }); - node1.addEventListener("get-my-rooms", String.class, (client, data, ackSender) -> { - if (ackSender.isAckRequested()) { - ackSender.sendAckData(client.getAllRooms()); - } - }); - node1.addConnectListener(client -> { - Map> params = - client.getHandshakeData().getUrlParams(); - List joinParams = params.get("join"); - if (joinParams == null || joinParams.isEmpty()) { - return; - } - Set rooms = joinParams.stream() - .flatMap(v -> Arrays.stream(v.split(","))) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - rooms.forEach(client::joinRoom); - }); - node1.start(); - port1 = cfg1.getPort(); - - // ---------- NODE 2 ---------- - Configuration cfg2 = new Configuration(); - DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg2); - cfg2.setHostname("127.0.0.1"); - cfg2.setPort(findAvailablePort()); - - mc2 = MONGO_CONTAINER.createClient(); - cfg2.setStoreFactory( - new MemoryStoreFactory( - new MongoEventStore.Builder(mc2, DB_NAME) - .eventStoreMode(EventStoreMode.MULTI_CHANNEL) - .build() - ) - ); - - node2 = new SocketIOServer(cfg2); - node2.addEventListener("join-room", String.class, (c, room, ack) -> { - c.joinRoom(room); - c.sendEvent("join-ok", "OK"); - }); - node2.addEventListener("leave-room", String.class, (c, room, ack) -> { - c.leaveRoom(room); - c.sendEvent("leave-ok", "OK"); - }); - node2.addEventListener("get-my-rooms", String.class, (client, data, ackSender) -> { - if (ackSender.isAckRequested()) { - ackSender.sendAckData(client.getAllRooms()); - } - }); - node2.addConnectListener(client -> { - Map> params = - client.getHandshakeData().getUrlParams(); - List joinParams = params.get("join"); - if (joinParams == null || joinParams.isEmpty()) { - return; - } - Set rooms = joinParams.stream() - .flatMap(v -> Arrays.stream(v.split(","))) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - rooms.forEach(client::joinRoom); - }); - node2.start(); - port2 = cfg2.getPort(); - } - - @AfterAll - public void stop() { - if (node1 != null) { - node1.stop(); - } - if (node2 != null) { - node2.stop(); - } - if (mc1 != null) { - mc1.close(); - } - if (mc2 != null) { - mc2.close(); - } - MONGO_CONTAINER.stop(); - } -} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java deleted file mode 100644 index 3b93b2ae..00000000 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/DistributedMongoSingleChannelMemoryTest.java +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Copyright (c) 2025 The Socketio4j Project - * Parent project : Copyright (c) 2012-2025 Nikita Koksharov - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.socketio4j.socketio.integration; - -import java.net.ServerSocket; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.TestInstance; - -import com.mongodb.client.MongoClient; - -import com.socketio4j.socketio.Configuration; -import com.socketio4j.socketio.SocketIOServer; -import com.socketio4j.socketio.store.CustomizedMongoContainer; -import com.socketio4j.socketio.store.event.EventStoreMode; -import com.socketio4j.socketio.store.memory.MemoryStoreFactory; -import com.socketio4j.socketio.store.mongo.MongoEventStore; - - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class DistributedMongoSingleChannelMemoryTest extends DistributedCommonTest { - - private static final CustomizedMongoContainer MONGO_CONTAINER = - new CustomizedMongoContainer(); - - private static final String DB_NAME = "socketio_test"; - - private MongoClient mc1; - private MongoClient mc2; - - private int findAvailablePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } - - @BeforeAll - public void setup() throws Exception { - - MONGO_CONTAINER.start(); - - // ---------- NODE 1 ---------- - Configuration cfg1 = new Configuration(); - DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg1); - cfg1.setHostname("127.0.0.1"); - cfg1.setPort(findAvailablePort()); - - mc1 = MONGO_CONTAINER.createClient(); - cfg1.setStoreFactory( - new MemoryStoreFactory( - new MongoEventStore.Builder(mc1, DB_NAME) - .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) - .build() - ) - ); - - node1 = new SocketIOServer(cfg1); - node1.addEventListener("join-room", String.class, (c, room, ack) -> { - c.joinRoom(room); - c.sendEvent("join-ok", "OK"); - }); - node1.addEventListener("leave-room", String.class, (c, room, ack) -> { - c.leaveRoom(room); - c.sendEvent("leave-ok", "OK"); - }); - node1.addEventListener("get-my-rooms", String.class, (client, data, ackSender) -> { - if (ackSender.isAckRequested()) { - ackSender.sendAckData(client.getAllRooms()); - } - }); - node1.addConnectListener(client -> { - Map> params = - client.getHandshakeData().getUrlParams(); - List joinParams = params.get("join"); - if (joinParams == null || joinParams.isEmpty()) { - return; - } - Set rooms = joinParams.stream() - .flatMap(v -> Arrays.stream(v.split(","))) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - rooms.forEach(client::joinRoom); - }); - node1.start(); - port1 = cfg1.getPort(); - - // ---------- NODE 2 ---------- - Configuration cfg2 = new Configuration(); - DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg2); - cfg2.setHostname("127.0.0.1"); - cfg2.setPort(findAvailablePort()); - - mc2 = MONGO_CONTAINER.createClient(); - cfg2.setStoreFactory( - new MemoryStoreFactory( - new MongoEventStore.Builder(mc2, DB_NAME) - .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) - .build() - ) - ); - - node2 = new SocketIOServer(cfg2); - node2.addEventListener("join-room", String.class, (c, room, ack) -> { - c.joinRoom(room); - c.sendEvent("join-ok", "OK"); - }); - node2.addEventListener("leave-room", String.class, (c, room, ack) -> { - c.leaveRoom(room); - c.sendEvent("leave-ok", "OK"); - }); - node2.addEventListener("get-my-rooms", String.class, (client, data, ackSender) -> { - if (ackSender.isAckRequested()) { - ackSender.sendAckData(client.getAllRooms()); - } - }); - node2.addConnectListener(client -> { - Map> params = - client.getHandshakeData().getUrlParams(); - List joinParams = params.get("join"); - if (joinParams == null || joinParams.isEmpty()) { - return; - } - Set rooms = joinParams.stream() - .flatMap(v -> Arrays.stream(v.split(","))) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - rooms.forEach(client::joinRoom); - }); - node2.start(); - port2 = cfg2.getPort(); - } - - @AfterAll - public void stop() { - if (node1 != null) { - node1.stop(); - } - if (node2 != null) { - node2.stop(); - } - if (mc1 != null) { - mc1.close(); - } - if (mc2 != null) { - mc2.close(); - } - MONGO_CONTAINER.stop(); - } -} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java new file mode 100644 index 00000000..2dc01362 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.integration.cluster; + +import com.socketio4j.socketio.TestResourceCleanup; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.parallel.ResourceLock; + +import com.mongodb.client.MongoClient; + +import com.socketio4j.socketio.Configuration; +import com.socketio4j.socketio.SocketIOServer; +import com.socketio4j.socketio.store.container.CustomizedMongoContainer; +import com.socketio4j.socketio.store.event.EventStoreMode; +import com.socketio4j.socketio.store.memory.MemoryStoreFactory; +import com.socketio4j.socketio.store.mongo.MongoEventStore; + +/** + * Runs {@link DistributedCommonTest} against all MongoDB-backed cluster variants while sharing + * one MongoDB Testcontainer for maximum execution speed and zero container setup overhead. + */ +@ResourceLock("EMBEDDED_MONGO") +public class DistributedMongoClusterTest { + + @SuppressWarnings("resource") + static final CustomizedMongoContainer MONGO_CONTAINER = new CustomizedMongoContainer(); + + private static final String DB_NAME = "socketio_test"; + + @BeforeAll + static void startMongo() { + if (!MONGO_CONTAINER.isRunning()) { + for (int attempt = 1; attempt <= 3; attempt++) { + try { + MONGO_CONTAINER.start(); + break; + } catch (Exception e) { + if (attempt == 3) throw new RuntimeException("Failed to start MongoDB container", e); + try { + Thread.sleep(500); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while starting MongoDB test container", error); + } + } + } + } + } + + @AfterAll + static void stopMongo() { + TestResourceCleanup.runAll("MongoDB test container cleanup", + () -> { if (MONGO_CONTAINER != null && MONGO_CONTAINER.isRunning()) MONGO_CONTAINER.stop(); }); + } + + /** + * Starts one node backed by its own {@link MongoClient}, so the two nodes talk to each + * other only through change streams — never through a shared in-process client. + */ + private static SocketIOServer startNode(MongoClient client, EventStoreMode mode, Configuration cfg) { + DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg); + cfg.setHostname("127.0.0.1"); + cfg.setPort(0); + cfg.setStoreFactory( + new MemoryStoreFactory( + new MongoEventStore.Builder(client, DB_NAME) + .eventStoreMode(mode) + .build() + ) + ); + + SocketIOServer node = new SocketIOServer(cfg); + DistributedClusterIntegrationSupport.attachDefaultRoomListeners(node); + node.start(); + return node; + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class SingleChannelMemoryTest extends DistributedCommonTest { + private MongoClient mc1; + private MongoClient mc2; + + @BeforeAll + void setupNodes() { + Configuration cfg1 = new Configuration(); + mc1 = MONGO_CONTAINER.createClient(); + node1 = startNode(mc1, EventStoreMode.SINGLE_CHANNEL, cfg1); + port1 = cfg1.getPort(); + + Configuration cfg2 = new Configuration(); + mc2 = MONGO_CONTAINER.createClient(); + node2 = startNode(mc2, EventStoreMode.SINGLE_CHANNEL, cfg2); + port2 = cfg2.getPort(); + } + + @AfterAll + void tearDownNodes() { + TestResourceCleanup.runAll("MongoDB cluster node cleanup", + () -> { if (node1 != null) node1.stop(); }, + () -> { if (node2 != null) node2.stop(); }, + () -> { if (mc1 != null) mc1.close(); }, + () -> { if (mc2 != null) mc2.close(); }); + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class MultiChannelMemoryTest extends DistributedCommonTest { + private MongoClient mc1; + private MongoClient mc2; + + @BeforeAll + void setupNodes() { + Configuration cfg1 = new Configuration(); + mc1 = MONGO_CONTAINER.createClient(); + node1 = startNode(mc1, EventStoreMode.MULTI_CHANNEL, cfg1); + port1 = cfg1.getPort(); + + Configuration cfg2 = new Configuration(); + mc2 = MONGO_CONTAINER.createClient(); + node2 = startNode(mc2, EventStoreMode.MULTI_CHANNEL, cfg2); + port2 = cfg2.getPort(); + } + + @AfterAll + void tearDownNodes() { + TestResourceCleanup.runAll("MongoDB cluster node cleanup", + () -> { if (node1 != null) node1.stop(); }, + () -> { if (node2 != null) node2.stop(); }, + () -> { if (mc1 != null) mc1.close(); }, + () -> { if (mc2 != null) mc2.close(); }); + } + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java similarity index 98% rename from netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java rename to netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java index 3d20648f..b2cc7361 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/CustomizedMongoContainer.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.socketio4j.socketio.store; +package com.socketio4j.socketio.store.container; import java.time.Duration; import java.util.concurrent.TimeUnit; From f8bbd74fd94d123910be6d3429718a4c90da64a0 Mon Sep 17 00:00:00 2001 From: dengliming Date: Sat, 29 Aug 2026 16:18:16 +0800 Subject: [PATCH 08/18] Abort the subscription when the TTL index cannot be applied Logging and continuing left an event collection without retention, so published documents accumulated without bound and nothing surfaced it. Setup failures now propagate, matching the other stores, while an index-options conflict is still reconciled with collMod. --- .../socketio/store/mongo/MongoEventStore.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index a34f045b..749deba4 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -323,9 +323,10 @@ public void shutdown0() { * conflict and leaves the old retention period in place. That conflict is caught * here and the TTL is changed in place with {@code collMod}. *

- * Any other failure (missing privileges, an unsupported server) means events may - * never expire and the collection grows without bound, so log it at warn rather - * than hiding it; subscription itself still works, so this must not abort startup. + * Any other failure (missing privileges, an unsupported server) aborts the + * subscription: without the index, published events never expire and the collection + * grows without bound, which is an operational problem an operator must see rather + * than find later in a full database. */ private void ensureTtlIndex(MongoCollection collection) { try { @@ -335,8 +336,7 @@ private void ensureTtlIndex(MongoCollection collection) { ); } catch (MongoCommandException e) { if (e.getErrorCode() != INDEX_OPTIONS_CONFLICT) { - logTtlIndexFailure(collection, e); - return; + throw ttlIndexFailure(collection, e); } try { database.runCommand( @@ -345,18 +345,18 @@ private void ensureTtlIndex(MongoCollection collection) { .append("expireAfterSeconds", ttlSeconds))); log.info("Updated TTL index on {} to {} seconds", collection.getNamespace(), ttlSeconds); - } catch (Exception ce) { - logTtlIndexFailure(collection, ce); + } catch (RuntimeException ce) { + throw ttlIndexFailure(collection, ce); } - } catch (Exception e) { - logTtlIndexFailure(collection, e); + } catch (RuntimeException e) { + throw ttlIndexFailure(collection, e); } } - private void logTtlIndexFailure(MongoCollection collection, Exception e) { - log.warn("Failed to apply TTL index of {}s on {}; published events may never " - + "expire or may use a stale retention period", - ttlSeconds, collection.getNamespace(), e); + private IllegalStateException ttlIndexFailure(MongoCollection collection, Exception cause) { + return new IllegalStateException("Failed to apply the TTL index of " + ttlSeconds + + "s on " + collection.getNamespace() + + "; published events would never expire", cause); } /** From e3610d0100d9a62d0df608b11ed5997b21aa89e8 Mon Sep 17 00:00:00 2001 From: Liming Deng Date: Sat, 29 Aug 2026 16:22:05 +0800 Subject: [PATCH 09/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../com/socketio4j/socketio/store/mongo/MongoEventStore.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 749deba4..0df0e3a7 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -250,8 +250,7 @@ public void subscribe0( } } } finally { - // Never leave subscribe0 blocked if the stream failed to open. - opened.countDown(); + // Clear the cursor; subscribe0 uses a startup timeout to avoid blocking indefinitely. handle.setCursor(null); } } From 29ffa184d5c5a351d8fe9fe35cbf96d79c898e69 Mon Sep 17 00:00:00 2001 From: dengliming Date: Sat, 29 Aug 2026 16:25:52 +0800 Subject: [PATCH 10/18] Fix review comments on MongoDB adapter --- .../com/socketio4j/socketio/store/mongo/MongoEventStore.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 0df0e3a7..296f6cf7 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -99,7 +99,8 @@ public class MongoEventStore implements EventStore { private final ExecutorService watcherExecutor = Executors.newCachedThreadPool(r -> { - Thread t = new Thread(r, "socketio-mongo-watcher"); + Thread t = new Thread(r); + t.setName("socketio-mongo-watcher-" + t.getId()); t.setDaemon(true); return t; }); From f5c32ce472fb777b0afb910bea1db7c0f779e77e Mon Sep 17 00:00:00 2001 From: dengliming Date: Sat, 29 Aug 2026 17:30:11 +0800 Subject: [PATCH 11/18] Use the shared EventMessage ObjectMapper in the MongoDB store A plain ObjectMapper writes byte[] as a base64 string that comes back as a String, so binary payloads were corrupted across nodes. Use the same EventMessageJsonSupport mapper the Kafka and NATS stores use. --- .../socketio/store/mongo/MongoEventStore.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 296f6cf7..bd4acaca 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -36,7 +36,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.mongodb.MongoCommandException; import com.mongodb.client.MongoClient; @@ -50,6 +49,7 @@ import com.socketio4j.socketio.store.event.EventListener; import com.socketio4j.socketio.store.event.EventMessage; +import com.socketio4j.socketio.store.event.EventMessageJsonSupport; import com.socketio4j.socketio.store.event.EventStore; import com.socketio4j.socketio.store.event.EventStoreMode; import com.socketio4j.socketio.store.event.EventStoreType; @@ -81,12 +81,8 @@ public class MongoEventStore implements EventStore { /** MongoDB error code raised when an index exists with the same key but different options. */ private static final int INDEX_OPTIONS_CONFLICT = 85; - private static final ObjectMapper MAPPER; - - static { - MAPPER = new ObjectMapper(); - MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - } + /** Shared mapper: keeps byte[] payloads lossless, as the Kafka and NATS stores do. */ + private static final ObjectMapper MAPPER = EventMessageJsonSupport.createObjectMapper(); private final MongoDatabase database; private final Long nodeId; From c30414129f0bdff772230d44d1e9ce5a5a9c34e7 Mon Sep 17 00:00:00 2001 From: dengliming Date: Sat, 29 Aug 2026 18:59:14 +0800 Subject: [PATCH 12/18] Fix checkstyle violations and filter the change stream server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkstyle (bound to verify, so this fails CI) reported 10 errors in MongoEventStore: an extra blank line inside the com import group, five inline conditionals, and three RuntimeException catches. The catches are narrowed to the type actually thrown — RejectedExecutionException for submit, MongoException for the TTL index setup. Also watch only insert operations instead of filtering in process, so TTL expiry deletes no longer reach every watcher, and pin the test container to mongo:7.0.40 rather than the floating 7.0 tag. --- .../socketio/store/mongo/MongoEventStore.java | 58 +++++++++++++++---- .../container/CustomizedMongoContainer.java | 2 +- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index bd4acaca..d89608e5 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -18,7 +18,9 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.Collections; import java.util.Date; +import java.util.List; import java.util.Objects; import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; @@ -27,10 +29,12 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.bson.Document; +import org.bson.conversions.Bson; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -38,15 +42,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mongodb.MongoCommandException; +import com.mongodb.MongoException; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoCursor; import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.Filters; import com.mongodb.client.model.IndexOptions; import com.mongodb.client.model.Indexes; import com.mongodb.client.model.changestream.ChangeStreamDocument; import com.mongodb.client.model.changestream.OperationType; - import com.socketio4j.socketio.store.event.EventListener; import com.socketio4j.socketio.store.event.EventMessage; import com.socketio4j.socketio.store.event.EventMessageJsonSupport; @@ -78,6 +84,14 @@ public class MongoEventStore implements EventStore { private static final long DEFAULT_TTL_SECONDS = 60; private static final long WATCH_STARTUP_TIMEOUT_SECONDS = 10; + /** + * Server-side filter for the change stream: only inserts carry published events, and + * TTL expiry deletes a batch of documents about once a minute — events every watcher + * would otherwise receive and decode only to drop. + */ + private static final List INSERT_ONLY = Collections.singletonList( + Aggregates.match(Filters.eq("operationType", "insert"))); + /** MongoDB error code raised when an index exists with the same key but different options. */ private static final int INDEX_OPTIONS_CONFLICT = 85; @@ -121,10 +135,26 @@ public MongoEventStore(@NotNull MongoClient mongoClient, this.database = mongoClient.getDatabase( Objects.requireNonNull(databaseName, "databaseName")); - this.nodeId = nodeId != null ? nodeId : getNodeId(); - this.eventStoreMode = eventStoreMode != null ? eventStoreMode : EventStoreMode.MULTI_CHANNEL; - this.collectionPrefix = collectionPrefix != null ? collectionPrefix : DEFAULT_COLLECTION_PREFIX; - this.ttlSeconds = ttlSeconds > 0 ? ttlSeconds : DEFAULT_TTL_SECONDS; + if (nodeId != null) { + this.nodeId = nodeId; + } else { + this.nodeId = getNodeId(); + } + if (eventStoreMode != null) { + this.eventStoreMode = eventStoreMode; + } else { + this.eventStoreMode = EventStoreMode.MULTI_CHANNEL; + } + if (collectionPrefix != null) { + this.collectionPrefix = collectionPrefix; + } else { + this.collectionPrefix = DEFAULT_COLLECTION_PREFIX; + } + if (ttlSeconds > 0) { + this.ttlSeconds = ttlSeconds; + } else { + this.ttlSeconds = DEFAULT_TTL_SECONDS; + } } @Override @@ -185,7 +215,10 @@ public void subscribe0( // between would leave the watcher running but unregistered, still delivering // events after unsubscribe. watchers.compute(type, (k, queue) -> { - Queue q = queue != null ? queue : new ConcurrentLinkedQueue<>(); + Queue q = queue; + if (q == null) { + q = new ConcurrentLinkedQueue<>(); + } q.add(handle); return q; }); @@ -193,7 +226,7 @@ public void subscribe0( Runnable watcher = () -> { while (!handle.stopped.get()) { try (MongoCursor> cursor = - collection.watch().cursor()) { + collection.watch(INSERT_ONLY).cursor()) { handle.setCursor(cursor); opened.countDown(); @@ -255,7 +288,7 @@ public void subscribe0( try { watcherExecutor.submit(watcher); - } catch (RuntimeException e) { + } catch (RejectedExecutionException e) { // Nothing will ever run for this handle, so do not leave it registered. unregister(type, handle); handle.stop(); @@ -280,7 +313,10 @@ public void subscribe0( private void unregister(EventType type, WatcherHandle handle) { watchers.computeIfPresent(type, (k, queue) -> { queue.remove(handle); - return queue.isEmpty() ? null : queue; + if (queue.isEmpty()) { + return null; + } + return queue; }); } @@ -341,10 +377,10 @@ private void ensureTtlIndex(MongoCollection collection) { .append("expireAfterSeconds", ttlSeconds))); log.info("Updated TTL index on {} to {} seconds", collection.getNamespace(), ttlSeconds); - } catch (RuntimeException ce) { + } catch (MongoException ce) { throw ttlIndexFailure(collection, ce); } - } catch (RuntimeException e) { + } catch (MongoException e) { throw ttlIndexFailure(collection, e); } } diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java index b2cc7361..132397c3 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java @@ -40,7 +40,7 @@ public class CustomizedMongoContainer extends GenericContainer Date: Mon, 31 Aug 2026 16:38:00 +0800 Subject: [PATCH 13/18] Move the MongoDB store to the reactive streams driver publish0 runs on the Netty event loop (Namespace join/leave, AuthorizeHandler CONNECT, SocketIOChannelInitializer DISCONNECT, broadcast DISPATCH), where the sync driver blocked it for a full round trip. The insert is now handed to the driver and only its failure logged, the model KafkaEventStore.publish0 uses. The change stream replaces the watcher threads and cursor lifecycle with a Subscriber: cancellation on unsubscribe, and reopening on error or completion resuming after the last delivered event. Since a reactive stream gives no signal for "the cursor is open", subscribe0 no longer waits for one; it reads the server's operation time first and starts the stream there, so events published while the stream is opening are still delivered. Index setup still blocks, but it runs while subscribing rather than on the event loop. --- netty-socketio-core/pom.xml | 2 +- .../socketio/store/mongo/MongoEventStore.java | 397 +++++++++++++----- .../src/main/java11/module-info.java | 3 +- .../cluster/DistributedMongoClusterTest.java | 2 +- .../container/CustomizedMongoContainer.java | 4 +- pom.xml | 2 +- 6 files changed, 288 insertions(+), 122 deletions(-) diff --git a/netty-socketio-core/pom.xml b/netty-socketio-core/pom.xml index f45fe31e..7c4be884 100644 --- a/netty-socketio-core/pom.xml +++ b/netty-socketio-core/pom.xml @@ -145,7 +145,7 @@ org.mongodb - mongodb-driver-sync + mongodb-driver-reactivestreams provided diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index d89608e5..a498f698 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -23,36 +23,43 @@ import java.util.List; import java.util.Objects; import java.util.Queue; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import org.bson.BsonDocument; +import org.bson.BsonTimestamp; import org.bson.Document; import org.bson.conversions.Bson; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.ObjectMapper; import com.mongodb.MongoCommandException; import com.mongodb.MongoException; -import com.mongodb.client.MongoClient; -import com.mongodb.client.MongoCollection; -import com.mongodb.client.MongoCursor; -import com.mongodb.client.MongoDatabase; import com.mongodb.client.model.Aggregates; import com.mongodb.client.model.Filters; import com.mongodb.client.model.IndexOptions; import com.mongodb.client.model.Indexes; import com.mongodb.client.model.changestream.ChangeStreamDocument; -import com.mongodb.client.model.changestream.OperationType; +import com.mongodb.client.result.InsertOneResult; +import com.mongodb.reactivestreams.client.ChangeStreamPublisher; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoCollection; +import com.mongodb.reactivestreams.client.MongoDatabase; import com.socketio4j.socketio.store.event.EventListener; import com.socketio4j.socketio.store.event.EventMessage; import com.socketio4j.socketio.store.event.EventMessageJsonSupport; @@ -69,6 +76,11 @@ * events to subscribers. Each event type maps to its own collection (MULTI_CHANNEL) * or all events go into one collection (SINGLE_CHANNEL). *

+ * Built on the reactive streams driver: {@code publish0} is called from the Netty + * event loop, so the insert is handed to the driver and never waited on. Only the + * one-off setup done while subscribing (index creation, reading the cluster time) + * blocks, and that runs on the thread starting the server. + *

* A TTL index is created on each collection to automatically expire documents * after a configurable retention period (default 60 seconds), preventing * unbounded data growth. @@ -82,7 +94,12 @@ public class MongoEventStore implements EventStore { private static final String DEFAULT_COLLECTION_PREFIX = "socketio_events_"; private static final long DEFAULT_TTL_SECONDS = 60; - private static final long WATCH_STARTUP_TIMEOUT_SECONDS = 10; + + /** How long a blocking setup command may take before subscribing is given up on. */ + private static final long SETUP_TIMEOUT_SECONDS = 10; + + /** How long to wait before reopening a change stream that ended or failed. */ + private static final long REOPEN_DELAY_MILLIS = 1000; /** * Server-side filter for the change stream: only inserts carry published events, and @@ -107,8 +124,8 @@ public class MongoEventStore implements EventStore { private final ConcurrentMap> watchers = new ConcurrentHashMap<>(); - private final ExecutorService watcherExecutor = - Executors.newCachedThreadPool(r -> { + private final ScheduledExecutorService watcherExecutor = + Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r); t.setName("socketio-mongo-watcher-" + t.getId()); t.setDaemon(true); @@ -177,19 +194,23 @@ public void publish0(EventType type, EventMessage msg) { msg.setNodeId(nodeId); String collectionName = getCollectionName(type); - MongoCollection collection = database.getCollection(collectionName); byte[] data; try { data = MAPPER.writeValueAsBytes(msg); } catch (Exception e) { - throw new RuntimeException("Failed to serialize EventMessage", e); + throw new IllegalStateException("Failed to serialize EventMessage", e); } Document doc = new Document() .append("nodeId", nodeId) .append("eventType", type.name()) .append("createdAt", new Date()) .append("payload", new String(data, StandardCharsets.UTF_8)); - collection.insertOne(doc); + + // Fire and forget: this runs on the Netty event loop, so the insert is handed to + // the driver and its outcome only logged, the model KafkaEventStore.publish0 uses. + database.getCollection(collectionName) + .insertOne(doc) + .subscribe(new PublishSubscriber(type, collectionName)); } @Override @@ -205,15 +226,15 @@ public void subscribe0( ensureTtlIndex(collection); - WatcherHandle handle = new WatcherHandle(); - // Opening the change stream is async, but events published before the - // cursor exists are lost. Let subscribe0 block until it is open. - CountDownLatch opened = new CountDownLatch(1); + // The change stream opens asynchronously, so events published in between would be + // lost. Starting it at the operation time read here covers that window instead of + // making subscribe0 wait for a cursor it cannot observe. + WatcherHandle handle = new WatcherHandle(currentOperationTime()); - // Register before submitting, and in a single atomic map operation: with a - // separate computeIfAbsent + add, an unsubscribe0 dropping the queue in - // between would leave the watcher running but unregistered, still delivering - // events after unsubscribe. + // Register before opening the stream, and in a single atomic map operation: with a + // separate computeIfAbsent + add, an unsubscribe0 dropping the queue in between + // would leave the watcher running but unregistered, still delivering events after + // unsubscribe. watchers.compute(type, (k, queue) -> { Queue q = queue; if (q == null) { @@ -223,87 +244,31 @@ public void subscribe0( return q; }); - Runnable watcher = () -> { - while (!handle.stopped.get()) { - try (MongoCursor> cursor = - collection.watch(INSERT_ONLY).cursor()) { - handle.setCursor(cursor); - opened.countDown(); - - while (!handle.stopped.get() && cursor.hasNext()) { - if (handle.stopped.get()) { - break; - } - - ChangeStreamDocument change = cursor.next(); - - if (change.getOperationType() != OperationType.INSERT) { - continue; - } - - if (change.getFullDocument() == null) { - continue; - } - - try { - Document doc = change.getFullDocument(); - - if (EventStoreMode.MULTI_CHANNEL.equals(eventStoreMode)) { - String eventTypeName = doc.getString("eventType"); - if (eventTypeName != null - && !type.name().equals(eventTypeName)) { - continue; - } - } - - String payload = doc.getString("payload"); - if (payload == null) { - continue; - } - T event = MAPPER.readValue(payload, clazz); - if (!nodeId.equals(event.getNodeId())) { - listener.onMessage(event); - } - } catch (Exception e) { - log.warn("Failed to process change event on {}", collectionName, e); - } - } - } catch (Exception e) { - if (!handle.stopped.get()) { - log.warn("Change stream interrupted on {}, reconnecting...", - collectionName, e); - try { - Thread.sleep(1000); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - break; - } - } - } finally { - // Clear the cursor; subscribe0 uses a startup timeout to avoid blocking indefinitely. - handle.setCursor(null); - } - } - }; + watch(collection, type, handle, listener, clazz); + } - try { - watcherExecutor.submit(watcher); - } catch (RejectedExecutionException e) { - // Nothing will ever run for this handle, so do not leave it registered. - unregister(type, handle); - handle.stop(); - throw e; + /** + * Opens the change stream, resuming after the last delivered event when this is a + * reopen, and otherwise starting at the operation time captured while subscribing. + */ + private void watch(MongoCollection collection, + EventType type, + WatcherHandle handle, + EventListener listener, + Class clazz) { + if (handle.stopped.get()) { + return; } - try { - if (!opened.await(WATCH_STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - log.warn("Change stream on {} not established within {}s; " - + "early events may be missed", - collectionName, WATCH_STARTUP_TIMEOUT_SECONDS); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + ChangeStreamPublisher stream = collection.watch(INSERT_ONLY); + BsonDocument resumeToken = handle.resumeToken(); + if (resumeToken != null) { + stream = stream.resumeAfter(resumeToken); + } else if (handle.startAt() != null) { + stream = stream.startAtOperationTime(handle.startAt()); } + + stream.subscribe(new ChangeSubscriber(collection, type, handle, listener, clazz)); } /** @@ -362,19 +327,19 @@ public void shutdown0() { */ private void ensureTtlIndex(MongoCollection collection) { try { - collection.createIndex( + await(collection.createIndex( Indexes.ascending("createdAt"), new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS) - ); + )); } catch (MongoCommandException e) { if (e.getErrorCode() != INDEX_OPTIONS_CONFLICT) { throw ttlIndexFailure(collection, e); } try { - database.runCommand( + await(database.runCommand( new Document("collMod", collection.getNamespace().getCollectionName()) .append("index", new Document("keyPattern", new Document("createdAt", 1)) - .append("expireAfterSeconds", ttlSeconds))); + .append("expireAfterSeconds", ttlSeconds)))); log.info("Updated TTL index on {} to {} seconds", collection.getNamespace(), ttlSeconds); } catch (MongoException ce) { @@ -391,6 +356,70 @@ private IllegalStateException ttlIndexFailure(MongoCollection collecti + "; published events would never expire", cause); } + /** + * Reads the server's current operation time, used as the change stream start point. + * Returns {@code null} when the deployment does not report one, in which case the + * stream simply starts at whatever the server considers now. + */ + private BsonTimestamp currentOperationTime() { + Document result = await(database.runCommand(new Document("ping", 1))); + if (result == null) { + return null; + } + Object operationTime = result.get("operationTime"); + if (operationTime instanceof BsonTimestamp) { + return (BsonTimestamp) operationTime; + } + return null; + } + + /** + * Subscribes to a one-shot publisher and waits for it, so the setup done while + * subscribing keeps its ordering and its failures. Never called from the event loop. + */ + private static T await(Publisher publisher) { + final CompletableFuture future = new CompletableFuture(); + publisher.subscribe(new Subscriber() { + private T value; + + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(1); + } + + @Override + public void onNext(T item) { + value = item; + } + + @Override + public void onError(Throwable error) { + future.completeExceptionally(error); + } + + @Override + public void onComplete() { + future.complete(value); + } + }); + + try { + return future.get(SETUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for MongoDB", e); + } catch (TimeoutException e) { + throw new IllegalStateException( + "MongoDB did not respond within " + SETUP_TIMEOUT_SECONDS + "s", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new IllegalStateException("MongoDB command failed", cause); + } + } + /** * Rejects a subscription whose {@link EventType} does not match the configured mode. *

@@ -419,37 +448,173 @@ private String getCollectionName(EventType type) { return collectionPrefix + type.name(); } - private static void closeCursorQuietly(MongoCursor cursor) { - if (cursor != null) { + /** Logs a failed insert; a published event is never retried, as in the Kafka store. */ + private static final class PublishSubscriber implements Subscriber { + + private final EventType type; + private final String collectionName; + + PublishSubscriber(EventType type, String collectionName) { + this.type = type; + this.collectionName = collectionName; + } + + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(1); + } + + @Override + public void onNext(InsertOneResult result) { + // the insert result carries nothing this store needs + } + + @Override + public void onError(Throwable error) { + log.warn("Failed to publish {} to {}", type, collectionName, error); + } + + @Override + public void onComplete() { + // nothing to do + } + } + + /** + * Delivers change events to one listener and reopens the stream when it ends, which + * replaces the reconnect loop a blocking cursor needed. + */ + private final class ChangeSubscriber + implements Subscriber> { + + private final MongoCollection collection; + private final EventType type; + private final WatcherHandle handle; + private final EventListener listener; + private final Class clazz; + + ChangeSubscriber(MongoCollection collection, + EventType type, + WatcherHandle handle, + EventListener listener, + Class clazz) { + this.collection = collection; + this.type = type; + this.handle = handle; + this.listener = listener; + this.clazz = clazz; + } + + @Override + public void onSubscribe(Subscription subscription) { + handle.setSubscription(subscription); + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ChangeStreamDocument change) { + handle.setResumeToken(change.getResumeToken()); + + Document doc = change.getFullDocument(); + if (doc == null) { + return; + } + + if (EventStoreMode.MULTI_CHANNEL.equals(eventStoreMode)) { + String eventTypeName = doc.getString("eventType"); + if (eventTypeName != null && !type.name().equals(eventTypeName)) { + return; + } + } + + String payload = doc.getString("payload"); + if (payload == null) { + return; + } + try { - cursor.close(); + T event = MAPPER.readValue(payload, clazz); + if (!nodeId.equals(event.getNodeId())) { + listener.onMessage(event); + } } catch (Exception e) { - // ignore + log.warn("Failed to process change event on {}", collection.getNamespace(), e); + } + } + + @Override + public void onError(Throwable error) { + if (handle.stopped.get()) { + return; + } + log.warn("Change stream on {} failed, reopening...", collection.getNamespace(), error); + reopen(); + } + + @Override + public void onComplete() { + if (handle.stopped.get()) { + return; + } + // The server ended the stream, for instance because the collection was dropped. + reopen(); + } + + private void reopen() { + try { + watcherExecutor.schedule( + () -> watch(collection, type, handle, listener, clazz), + REOPEN_DELAY_MILLIS, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException e) { + log.debug("Not reopening the change stream on {}, the store is shutting down", + collection.getNamespace()); } } } private static final class WatcherHandle { + final AtomicBoolean stopped = new AtomicBoolean(false); - private MongoCursor cursor; + + private final BsonTimestamp startAt; + private Subscription subscription; + private BsonDocument resumeToken; + + WatcherHandle(BsonTimestamp startAt) { + this.startAt = startAt; + } + + BsonTimestamp startAt() { + return startAt; + } /** - * Publishes the cursor so {@link #stop()} can close it and unblock - * {@code hasNext()}. If stop already happened, closes it right away — - * otherwise the watcher would block on a cursor nobody can close. + * Publishes the subscription so {@link #stop()} can cancel it. If stop already + * happened, cancels it right away — otherwise the stream would stay open with + * nobody left to close it. */ - synchronized void setCursor(MongoCursor cursor) { - if (cursor != null && stopped.get()) { - closeCursorQuietly(cursor); + synchronized void setSubscription(Subscription subscription) { + if (stopped.get()) { + subscription.cancel(); return; } - this.cursor = cursor; + this.subscription = subscription; + } + + synchronized void setResumeToken(BsonDocument resumeToken) { + this.resumeToken = resumeToken; + } + + synchronized BsonDocument resumeToken() { + return resumeToken; } synchronized void stop() { stopped.set(true); - closeCursorQuietly(cursor); - cursor = null; + if (subscription != null) { + subscription.cancel(); + subscription = null; + } } } diff --git a/netty-socketio-core/src/main/java11/module-info.java b/netty-socketio-core/src/main/java11/module-info.java index cf474c36..0983f6d7 100644 --- a/netty-socketio-core/src/main/java11/module-info.java +++ b/netty-socketio-core/src/main/java11/module-info.java @@ -80,7 +80,8 @@ requires static kafka.clients; requires static org.mongodb.bson; requires static org.mongodb.driver.core; - requires static org.mongodb.driver.sync.client; + requires static org.mongodb.driver.reactivestreams; + requires static org.reactivestreams; // ============================================================ // Optional Netty native transports — only if available diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java index 2dc01362..f864b825 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java @@ -24,7 +24,7 @@ import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.parallel.ResourceLock; -import com.mongodb.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoClient; import com.socketio4j.socketio.Configuration; import com.socketio4j.socketio.SocketIOServer; diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java index 132397c3..42b7063f 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/container/CustomizedMongoContainer.java @@ -24,8 +24,8 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.utility.DockerImageName; -import com.mongodb.client.MongoClient; -import com.mongodb.client.MongoClients; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoClients; /** * CI-safe customized MongoDB container for socketio4j tests. diff --git a/pom.xml b/pom.xml index 805a3973..2ef73630 100644 --- a/pom.xml +++ b/pom.xml @@ -355,7 +355,7 @@ org.mongodb - mongodb-driver-sync + mongodb-driver-reactivestreams ${mongodb.version} provided From 43de8867f8779f808776dad2214ca26ffad47dcb Mon Sep 17 00:00:00 2001 From: dengliming Date: Mon, 31 Aug 2026 16:57:01 +0800 Subject: [PATCH 14/18] Shut the Mongo stores down before the clients they read from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopping a node does not stop its event store — MemoryStoreFactory.shutdown() is a no-op and SocketIOServer never calls it — so the teardown closed the MongoClients while their change streams were still open. --- .../cluster/DistributedMongoClusterTest.java | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java index f864b825..2465a9e2 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java @@ -72,20 +72,14 @@ static void stopMongo() { } /** - * Starts one node backed by its own {@link MongoClient}, so the two nodes talk to each - * other only through change streams — never through a shared in-process client. + * Starts one node backed by its own store, so the two nodes talk to each other only + * through change streams — never through a shared in-process client. */ - private static SocketIOServer startNode(MongoClient client, EventStoreMode mode, Configuration cfg) { + private static SocketIOServer startNode(MongoEventStore store, Configuration cfg) { DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg); cfg.setHostname("127.0.0.1"); cfg.setPort(0); - cfg.setStoreFactory( - new MemoryStoreFactory( - new MongoEventStore.Builder(client, DB_NAME) - .eventStoreMode(mode) - .build() - ) - ); + cfg.setStoreFactory(new MemoryStoreFactory(store)); SocketIOServer node = new SocketIOServer(cfg); DistributedClusterIntegrationSupport.attachDefaultRoomListeners(node); @@ -98,17 +92,25 @@ private static SocketIOServer startNode(MongoClient client, EventStoreMode mode, class SingleChannelMemoryTest extends DistributedCommonTest { private MongoClient mc1; private MongoClient mc2; + private MongoEventStore store1; + private MongoEventStore store2; @BeforeAll void setupNodes() { Configuration cfg1 = new Configuration(); mc1 = MONGO_CONTAINER.createClient(); - node1 = startNode(mc1, EventStoreMode.SINGLE_CHANNEL, cfg1); + store1 = new MongoEventStore.Builder(mc1, DB_NAME) + .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) + .build(); + node1 = startNode(store1, cfg1); port1 = cfg1.getPort(); Configuration cfg2 = new Configuration(); mc2 = MONGO_CONTAINER.createClient(); - node2 = startNode(mc2, EventStoreMode.SINGLE_CHANNEL, cfg2); + store2 = new MongoEventStore.Builder(mc2, DB_NAME) + .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) + .build(); + node2 = startNode(store2, cfg2); port2 = cfg2.getPort(); } @@ -117,6 +119,10 @@ void tearDownNodes() { TestResourceCleanup.runAll("MongoDB cluster node cleanup", () -> { if (node1 != null) node1.stop(); }, () -> { if (node2 != null) node2.stop(); }, + // Stopping a node leaves its event store running, so close the change + // streams before the clients they are reading from. + () -> { if (store1 != null) store1.shutdown(); }, + () -> { if (store2 != null) store2.shutdown(); }, () -> { if (mc1 != null) mc1.close(); }, () -> { if (mc2 != null) mc2.close(); }); } @@ -127,17 +133,25 @@ void tearDownNodes() { class MultiChannelMemoryTest extends DistributedCommonTest { private MongoClient mc1; private MongoClient mc2; + private MongoEventStore store1; + private MongoEventStore store2; @BeforeAll void setupNodes() { Configuration cfg1 = new Configuration(); mc1 = MONGO_CONTAINER.createClient(); - node1 = startNode(mc1, EventStoreMode.MULTI_CHANNEL, cfg1); + store1 = new MongoEventStore.Builder(mc1, DB_NAME) + .eventStoreMode(EventStoreMode.MULTI_CHANNEL) + .build(); + node1 = startNode(store1, cfg1); port1 = cfg1.getPort(); Configuration cfg2 = new Configuration(); mc2 = MONGO_CONTAINER.createClient(); - node2 = startNode(mc2, EventStoreMode.MULTI_CHANNEL, cfg2); + store2 = new MongoEventStore.Builder(mc2, DB_NAME) + .eventStoreMode(EventStoreMode.MULTI_CHANNEL) + .build(); + node2 = startNode(store2, cfg2); port2 = cfg2.getPort(); } @@ -146,6 +160,10 @@ void tearDownNodes() { TestResourceCleanup.runAll("MongoDB cluster node cleanup", () -> { if (node1 != null) node1.stop(); }, () -> { if (node2 != null) node2.stop(); }, + // Stopping a node leaves its event store running, so close the change + // streams before the clients they are reading from. + () -> { if (store1 != null) store1.shutdown(); }, + () -> { if (store2 != null) store2.shutdown(); }, () -> { if (mc1 != null) mc1.close(); }, () -> { if (mc2 != null) mc2.close(); }); } From 496e13a73b59caa4483521779ec8d683dd94605e Mon Sep 17 00:00:00 2001 From: dengliming Date: Mon, 31 Aug 2026 17:12:21 +0800 Subject: [PATCH 15/18] Wait for cancelled change streams before shutdown returns Cancelling a change stream is asynchronous. With a getMore still in flight, a caller closing its MongoClient right after shutdown left the cursor resuming against a closed cluster, which the driver logged with a stack trace per stream. shutdown0 now waits up to a second for the cancelled streams to end, returning as soon as they report termination. --- .../socketio/store/mongo/MongoEventStore.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index a498f698..19d5a08e 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -17,6 +17,7 @@ package com.socketio4j.socketio.store.mongo; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; @@ -27,6 +28,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; @@ -101,6 +103,14 @@ public class MongoEventStore implements EventStore { /** How long to wait before reopening a change stream that ended or failed. */ private static final long REOPEN_DELAY_MILLIS = 1000; + /** + * How long {@link #shutdown0()} waits for cancelled change streams to actually end. + * Cancelling is asynchronous, and the driver may still have a {@code getMore} in flight; + * if the caller closes its {@code MongoClient} before that lands, the cursor tries to + * resume against a closed cluster and the driver logs a failure per stream. + */ + private static final long CANCEL_GRACE_MILLIS = 1000; + /** * Server-side filter for the change stream: only inserts carry published events, and * TTL expiry deletes a batch of documents about once a minute — events every watcher @@ -298,8 +308,15 @@ public void unsubscribe0(EventType type) { @Override public void shutdown0() { + List cancelled = new ArrayList(); + for (Queue handles : watchers.values()) { + cancelled.addAll(handles); + } + Arrays.stream(EventType.values()).forEach(this::unsubscribe); watchers.clear(); + awaitCancellation(cancelled); + watcherExecutor.shutdown(); try { if (!watcherExecutor.awaitTermination(5, TimeUnit.SECONDS)) { @@ -311,6 +328,31 @@ public void shutdown0() { } } + /** + * Waits for the change streams cancelled by {@link #shutdown0()} to end, so that a + * caller closing its {@code MongoClient} right afterwards does not interrupt them + * mid-flight. The driver is not required to signal a cancelled subscriber at all, so + * this is bounded by {@link #CANCEL_GRACE_MILLIS} and returns early when it does. + */ + private void awaitCancellation(List handles) { + if (handles.isEmpty()) { + return; + } + + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(CANCEL_GRACE_MILLIS); + try { + for (WatcherHandle handle : handles) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return; + } + handle.awaitTermination(remaining); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + /** * Creates or reconciles the TTL index used to expire published events. *

@@ -545,6 +587,7 @@ public void onNext(ChangeStreamDocument change) { @Override public void onError(Throwable error) { if (handle.stopped.get()) { + handle.markTerminated(); return; } log.warn("Change stream on {} failed, reopening...", collection.getNamespace(), error); @@ -554,6 +597,7 @@ public void onError(Throwable error) { @Override public void onComplete() { if (handle.stopped.get()) { + handle.markTerminated(); return; } // The server ended the stream, for instance because the collection was dropped. @@ -576,6 +620,7 @@ private static final class WatcherHandle { final AtomicBoolean stopped = new AtomicBoolean(false); + private final CountDownLatch terminated = new CountDownLatch(1); private final BsonTimestamp startAt; private Subscription subscription; private BsonDocument resumeToken; @@ -614,8 +659,19 @@ synchronized void stop() { if (subscription != null) { subscription.cancel(); subscription = null; + } else { + // Nothing was ever opened, so there is nothing left to wait for. + terminated.countDown(); } } + + void markTerminated() { + terminated.countDown(); + } + + void awaitTermination(long nanos) throws InterruptedException { + terminated.await(nanos, TimeUnit.NANOSECONDS); + } } /** From 422b9047b4cfbba925bbeadabeb65f55b8c35c1b Mon Sep 17 00:00:00 2001 From: dengliming Date: Mon, 31 Aug 2026 17:23:36 +0800 Subject: [PATCH 16/18] Reject null subscribers and drop self-published events before parsing subscribe0 now rejects a null listener or class up front, as KafkaEventStore does, instead of failing with a bare NPE on a driver thread once the first change event arrives. A node also receives its own inserts, and those were deserialized in full before being dropped. The document carries the publishing nodeId, so compare that first; the check on the parsed event stays for documents written without the field. --- .../socketio/store/mongo/MongoEventStore.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 19d5a08e..dc489ea3 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -229,6 +229,9 @@ public void subscribe0( final EventListener listener, Class clazz) { + Objects.requireNonNull(listener); + Objects.requireNonNull(clazz); + validateSubscribe(type); String collectionName = getCollectionName(type); @@ -562,6 +565,12 @@ public void onNext(ChangeStreamDocument change) { return; } + // A node sees its own inserts too, so drop them on the document's own nodeId + // rather than paying for the deserialization first. + if (nodeId.equals(doc.get("nodeId"))) { + return; + } + if (EventStoreMode.MULTI_CHANNEL.equals(eventStoreMode)) { String eventTypeName = doc.getString("eventType"); if (eventTypeName != null && !type.name().equals(eventTypeName)) { @@ -576,6 +585,8 @@ public void onNext(ChangeStreamDocument change) { try { T event = MAPPER.readValue(payload, clazz); + // Belt and braces: a document written without the nodeId field still gets + // filtered on the value carried by the event itself. if (!nodeId.equals(event.getNodeId())) { listener.onMessage(event); } From 2f0ef77f66fe995b25df3a33211f10b757c98975 Mon Sep 17 00:00:00 2001 From: sanjomo Date: Fri, 18 Sep 2026 16:12:57 +0530 Subject: [PATCH 17/18] Enhance MongoEventStore change-streams and tests Improve MongoEventStore robustness and configurability: add WriteConcern and ReadPreference support, collection caching, safer TTL index reconciliation, backoff reconnect strategy, controlled demand/backpressure, proper handling of change-stream history-loss (clearing resume points), and idempotent shutdown/publish behavior. Refactor WatcherHandle for atomic subscription lifecycle and reconnect attempt tracking. Add builder methods for read/write options and expose getters. Update pom to include the new Mongo pub/sub test. Add comprehensive unit and integration tests (MongoEventStoreTest, MongoPubSubEventStoreTest, DistributedMongoJsClientInteropTest) exercising publish/subscribe, TTL/index handling, resume-point behavior, shutdown, and multi-node interop backed by a Testcontainer MongoDB. --- netty-socketio-core/pom.xml | 2 + .../socketio/store/mongo/MongoEventStore.java | 496 +++++++++++++----- .../DistributedMongoJsClientInteropTest.java | 122 +++++ .../event/MongoPubSubEventStoreTest.java | 164 ++++++ .../store/mongo/MongoEventStoreTest.java | 395 ++++++++++++++ 5 files changed, 1054 insertions(+), 125 deletions(-) create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/interop/DistributedMongoJsClientInteropTest.java create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/store/event/MongoPubSubEventStoreTest.java create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoEventStoreTest.java diff --git a/netty-socketio-core/pom.xml b/netty-socketio-core/pom.xml index 7c4be884..471d2cc8 100644 --- a/netty-socketio-core/pom.xml +++ b/netty-socketio-core/pom.xml @@ -290,6 +290,7 @@ **/store/RedissonStoreTest.java **/store/event/HazelcastRingBufferEventStoreTest.java **/store/event/RedisPubSubEventStoreTest.java + **/store/event/MongoPubSubEventStoreTest.java @@ -316,6 +317,7 @@ **/store/RedissonStoreTest.java **/store/event/HazelcastRingBufferEventStoreTest.java **/store/event/RedisPubSubEventStoreTest.java + **/store/event/MongoPubSubEventStoreTest.java diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index dc489ea3..9999ab8d 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -16,7 +16,6 @@ */ package com.socketio4j.socketio.store.mongo; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -24,6 +23,7 @@ import java.util.List; import java.util.Objects; import java.util.Queue; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; @@ -36,6 +36,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.bson.BsonDocument; import org.bson.BsonTimestamp; @@ -52,6 +54,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mongodb.MongoCommandException; import com.mongodb.MongoException; +import com.mongodb.ReadPreference; +import com.mongodb.WriteConcern; import com.mongodb.client.model.Aggregates; import com.mongodb.client.model.Filters; import com.mongodb.client.model.IndexOptions; @@ -75,12 +79,16 @@ * MongoDB Change Streams based EventStore. *

* Uses MongoDB Change Streams to watch for inserts on a collection and deliver - * events to subscribers. Each event type maps to its own collection (MULTI_CHANNEL) + * events to subscribers. Each event type maps to its own collection + * (MULTI_CHANNEL) * or all events go into one collection (SINGLE_CHANNEL). *

- * Built on the reactive streams driver: {@code publish0} is called from the Netty - * event loop, so the insert is handed to the driver and never waited on. Only the - * one-off setup done while subscribing (index creation, reading the cluster time) + * Built on the reactive streams driver: {@code publish0} is called from the + * Netty + * event loop, so the insert is handed to the driver and never waited on. Only + * the + * one-off setup done while subscribing (index creation, reading the cluster + * time) * blocks, and that runs on the thread starting the server. *

* A TTL index is created on each collection to automatically expire documents @@ -91,38 +99,41 @@ */ public class MongoEventStore implements EventStore { - private static final Logger log = - LoggerFactory.getLogger(MongoEventStore.class); + private static final Logger log = LoggerFactory.getLogger(MongoEventStore.class); private static final String DEFAULT_COLLECTION_PREFIX = "socketio_events_"; private static final long DEFAULT_TTL_SECONDS = 60; - /** How long a blocking setup command may take before subscribing is given up on. */ + /** + * How long a blocking setup command may take before subscribing is given up on. + */ private static final long SETUP_TIMEOUT_SECONDS = 10; - /** How long to wait before reopening a change stream that ended or failed. */ - private static final long REOPEN_DELAY_MILLIS = 1000; + /** Initial delay before reopening a change stream that ended or failed. */ + private static final long INITIAL_REOPEN_DELAY_MILLIS = 500; + + /** Maximum backoff delay for reopening a change stream. */ + private static final long MAX_REOPEN_DELAY_MILLIS = 8000; /** - * How long {@link #shutdown0()} waits for cancelled change streams to actually end. - * Cancelling is asynchronous, and the driver may still have a {@code getMore} in flight; - * if the caller closes its {@code MongoClient} before that lands, the cursor tries to - * resume against a closed cluster and the driver logs a failure per stream. + * How long {@link #shutdown0()} waits for cancelled change streams to actually + * end. + * Cancelling is asynchronous, and the driver may still have a {@code getMore} + * in flight; + * bounded by {@link #CANCEL_GRACE_MILLIS} and returns early once cancelled. */ private static final long CANCEL_GRACE_MILLIS = 1000; /** - * Server-side filter for the change stream: only inserts carry published events, and - * TTL expiry deletes a batch of documents about once a minute — events every watcher - * would otherwise receive and decode only to drop. + * MongoDB error code raised when an index exists with the same key but + * different options. */ - private static final List INSERT_ONLY = Collections.singletonList( - Aggregates.match(Filters.eq("operationType", "insert"))); - - /** MongoDB error code raised when an index exists with the same key but different options. */ private static final int INDEX_OPTIONS_CONFLICT = 85; - /** Shared mapper: keeps byte[] payloads lossless, as the Kafka and NATS stores do. */ + /** + * Shared mapper: keeps byte[] payloads lossless, as the Kafka and NATS stores + * do. + */ private static final ObjectMapper MAPPER = EventMessageJsonSupport.createObjectMapper(); private final MongoDatabase database; @@ -130,34 +141,69 @@ public class MongoEventStore implements EventStore { private final EventStoreMode eventStoreMode; private final String collectionPrefix; private final long ttlSeconds; + private final WriteConcern writeConcern; + private final ReadPreference readPreference; + + private final AtomicBoolean running = new AtomicBoolean(true); + + private final ConcurrentMap> collectionCache = new ConcurrentHashMap<>(); - private final ConcurrentMap> watchers = - new ConcurrentHashMap<>(); + private final Set indexedCollections = ConcurrentHashMap.newKeySet(); - private final ScheduledExecutorService watcherExecutor = - Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r); - t.setName("socketio-mongo-watcher-" + t.getId()); - t.setDaemon(true); - return t; - }); + private final ConcurrentMap> watchers = new ConcurrentHashMap<>(); + + private final ScheduledExecutorService watcherExecutor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r); + t.setName("socketio-mongo-watcher-" + t.getId()); + t.setDaemon(true); + return t; + }); /** * Creates a new MongoEventStore. * * @param mongoClient shared MongoDB client * @param databaseName database to use for event collections - * @param eventStoreMode SINGLE_CHANNEL or MULTI_CHANNEL (defaults to MULTI_CHANNEL) + * @param eventStoreMode SINGLE_CHANNEL or MULTI_CHANNEL (defaults to + * MULTI_CHANNEL) * @param nodeId node identifier used to ignore self-published events - * @param collectionPrefix prefix for collection names (defaults to "socketio_events_") - * @param ttlSeconds TTL in seconds for automatic document expiry (defaults to 60) + * @param collectionPrefix prefix for collection names (defaults to + * "socketio_events_") + * @param ttlSeconds TTL in seconds for automatic document expiry + * (defaults to 60) */ public MongoEventStore(@NotNull MongoClient mongoClient, - @NotNull String databaseName, - @Nullable EventStoreMode eventStoreMode, - @Nullable Long nodeId, - @Nullable String collectionPrefix, - long ttlSeconds) { + @NotNull String databaseName, + @Nullable EventStoreMode eventStoreMode, + @Nullable Long nodeId, + @Nullable String collectionPrefix, + long ttlSeconds) { + this(mongoClient, databaseName, eventStoreMode, nodeId, collectionPrefix, ttlSeconds, null, null); + } + + /** + * Creates a new MongoEventStore with custom write concern and read preference. + * + * @param mongoClient shared MongoDB client + * @param databaseName database to use for event collections + * @param eventStoreMode SINGLE_CHANNEL or MULTI_CHANNEL (defaults to + * MULTI_CHANNEL) + * @param nodeId node identifier used to ignore self-published events + * @param collectionPrefix prefix for collection names (defaults to + * "socketio_events_") + * @param ttlSeconds TTL in seconds for automatic document expiry + * (defaults to 60) + * @param writeConcern optional write concern for published events + * @param readPreference optional read preference for change stream operations + */ + public MongoEventStore(@NotNull MongoClient mongoClient, + @NotNull String databaseName, + @Nullable EventStoreMode eventStoreMode, + @Nullable Long nodeId, + @Nullable String collectionPrefix, + long ttlSeconds, + @Nullable WriteConcern writeConcern, + @Nullable ReadPreference readPreference) { Objects.requireNonNull(mongoClient, "mongoClient"); this.database = mongoClient.getDatabase( Objects.requireNonNull(databaseName, "databaseName")); @@ -182,6 +228,8 @@ public MongoEventStore(@NotNull MongoClient mongoClient, } else { this.ttlSeconds = DEFAULT_TTL_SECONDS; } + this.writeConcern = writeConcern; + this.readPreference = readPreference; } @Override @@ -199,14 +247,35 @@ public PublishMode getPublishMode() { return PublishMode.UNRELIABLE; } + public String getCollectionPrefix() { + return collectionPrefix; + } + + public long getTtlSeconds() { + return ttlSeconds; + } + + public WriteConcern getWriteConcern() { + return writeConcern; + } + + public ReadPreference getReadPreference() { + return readPreference; + } + @Override public void publish0(EventType type, EventMessage msg) { + if (!running.get()) { + log.warn("MongoEventStore is shut down; ignoring publish of {}", type); + return; + } + msg.setNodeId(nodeId); String collectionName = getCollectionName(type); - byte[] data; + String payload; try { - data = MAPPER.writeValueAsBytes(msg); + payload = MAPPER.writeValueAsString(msg); } catch (Exception e) { throw new IllegalStateException("Failed to serialize EventMessage", e); } @@ -214,11 +283,13 @@ public void publish0(EventType type, EventMessage msg) { .append("nodeId", nodeId) .append("eventType", type.name()) .append("createdAt", new Date()) - .append("payload", new String(data, StandardCharsets.UTF_8)); + .append("payload", payload); - // Fire and forget: this runs on the Netty event loop, so the insert is handed to - // the driver and its outcome only logged, the model KafkaEventStore.publish0 uses. - database.getCollection(collectionName) + // Fire and forget: this runs on the Netty event loop, so the insert is handed + // to + // the driver and its outcome only logged, the model KafkaEventStore.publish0 + // uses. + getCollection(collectionName) .insertOne(doc) .subscribe(new PublishSubscriber(type, collectionName)); } @@ -229,24 +300,34 @@ public void subscribe0( final EventListener listener, Class clazz) { + if (!running.get()) { + throw new IllegalStateException("MongoEventStore has been shut down"); + } + Objects.requireNonNull(listener); Objects.requireNonNull(clazz); validateSubscribe(type); String collectionName = getCollectionName(type); - MongoCollection collection = database.getCollection(collectionName); + MongoCollection collection = getCollection(collectionName); - ensureTtlIndex(collection); + if (indexedCollections.add(collectionName)) { + ensureTtlIndex(collection); + } - // The change stream opens asynchronously, so events published in between would be - // lost. Starting it at the operation time read here covers that window instead of + // The change stream opens asynchronously, so events published in between would + // be + // lost. Starting it at the operation time read here covers that window instead + // of // making subscribe0 wait for a cursor it cannot observe. WatcherHandle handle = new WatcherHandle(currentOperationTime()); - // Register before opening the stream, and in a single atomic map operation: with a + // Register before opening the stream, and in a single atomic map operation: + // with a // separate computeIfAbsent + add, an unsubscribe0 dropping the queue in between - // would leave the watcher running but unregistered, still delivering events after + // would leave the watcher running but unregistered, still delivering events + // after // unsubscribe. watchers.compute(type, (k, queue) -> { Queue q = queue; @@ -261,19 +342,35 @@ public void subscribe0( } /** - * Opens the change stream, resuming after the last delivered event when this is a - * reopen, and otherwise starting at the operation time captured while subscribing. + * Builds the server-side Change Stream aggregation pipeline. + *

+ * Filters for inserts only (ignoring TTL deletes) and drops self-published + * events on + * the MongoDB server before network transmission. + */ + private List createPipeline() { + return Collections.singletonList( + Aggregates.match(Filters.and( + Filters.eq("operationType", "insert"), + Filters.ne("fullDocument.nodeId", nodeId)))); + } + + /** + * Opens the change stream, resuming after the last delivered event when this is + * a + * reopen, and otherwise starting at the operation time captured while + * subscribing. */ private void watch(MongoCollection collection, - EventType type, - WatcherHandle handle, - EventListener listener, - Class clazz) { + EventType type, + WatcherHandle handle, + EventListener listener, + Class clazz) { if (handle.stopped.get()) { return; } - ChangeStreamPublisher stream = collection.watch(INSERT_ONLY); + ChangeStreamPublisher stream = collection.watch(createPipeline()); BsonDocument resumeToken = handle.resumeToken(); if (resumeToken != null) { stream = stream.resumeAfter(resumeToken); @@ -284,20 +381,6 @@ private void watch(MongoCollection collection stream.subscribe(new ChangeSubscriber(collection, type, handle, listener, clazz)); } - /** - * Removes one handle from its type's queue atomically, so it cannot race with - * the {@code compute} in {@link #subscribe0} or the removal in {@link #unsubscribe0}. - */ - private void unregister(EventType type, WatcherHandle handle) { - watchers.computeIfPresent(type, (k, queue) -> { - queue.remove(handle); - if (queue.isEmpty()) { - return null; - } - return queue; - }); - } - @Override public void unsubscribe0(EventType type) { Queue handles = watchers.remove(type); @@ -311,6 +394,10 @@ public void unsubscribe0(EventType type) { @Override public void shutdown0() { + if (!running.compareAndSet(true, false)) { + return; + } + List cancelled = new ArrayList(); for (Queue handles : watchers.values()) { cancelled.addAll(handles); @@ -332,10 +419,11 @@ public void shutdown0() { } /** - * Waits for the change streams cancelled by {@link #shutdown0()} to end, so that a - * caller closing its {@code MongoClient} right afterwards does not interrupt them - * mid-flight. The driver is not required to signal a cancelled subscriber at all, so - * this is bounded by {@link #CANCEL_GRACE_MILLIS} and returns early when it does. + * Waits for the change streams cancelled by {@link #shutdown0()} to end, so + * that a + * caller closing its {@code MongoClient} right afterwards does not interrupt + * them + * mid-flight. */ private void awaitCancellation(List handles) { if (handles.isEmpty()) { @@ -359,23 +447,28 @@ private void awaitCancellation(List handles) { /** * Creates or reconciles the TTL index used to expire published events. *

- * {@code createIndex} creates the collection when it does not exist yet and is a - * no-op for an identical index, but it never updates an existing {@code createdAt} - * index whose {@code expireAfterSeconds} differs — it fails with an index-options - * conflict and leaves the old retention period in place. That conflict is caught + * {@code createIndex} creates the collection when it does not exist yet and is + * a + * no-op for an identical index, but it never updates an existing + * {@code createdAt} + * index whose {@code expireAfterSeconds} differs — it fails with an + * index-options + * conflict and leaves the old retention period in place. That conflict is + * caught * here and the TTL is changed in place with {@code collMod}. *

* Any other failure (missing privileges, an unsupported server) aborts the - * subscription: without the index, published events never expire and the collection - * grows without bound, which is an operational problem an operator must see rather + * subscription: without the index, published events never expire and the + * collection + * grows without bound, which is an operational problem an operator must see + * rather * than find later in a full database. */ private void ensureTtlIndex(MongoCollection collection) { try { await(collection.createIndex( Indexes.ascending("createdAt"), - new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS) - )); + new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS))); } catch (MongoCommandException e) { if (e.getErrorCode() != INDEX_OPTIONS_CONFLICT) { throw ttlIndexFailure(collection, e); @@ -402,8 +495,10 @@ private IllegalStateException ttlIndexFailure(MongoCollection collecti } /** - * Reads the server's current operation time, used as the change stream start point. - * Returns {@code null} when the deployment does not report one, in which case the + * Reads the server's current operation time, used as the change stream start + * point. + * Returns {@code null} when the deployment does not report one, in which case + * the * stream simply starts at whatever the server considers now. */ private BsonTimestamp currentOperationTime() { @@ -420,15 +515,18 @@ private BsonTimestamp currentOperationTime() { /** * Subscribes to a one-shot publisher and waits for it, so the setup done while - * subscribing keeps its ordering and its failures. Never called from the event loop. + * subscribing keeps its ordering and its failures. Never called from the event + * loop. */ private static T await(Publisher publisher) { final CompletableFuture future = new CompletableFuture(); + final AtomicReference subRef = new AtomicReference(); publisher.subscribe(new Subscriber() { private T value; @Override public void onSubscribe(Subscription subscription) { + subRef.set(subscription); subscription.request(1); } @@ -451,9 +549,17 @@ public void onComplete() { try { return future.get(SETUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (InterruptedException e) { + Subscription s = subRef.get(); + if (s != null) { + s.cancel(); + } Thread.currentThread().interrupt(); throw new IllegalStateException("Interrupted while waiting for MongoDB", e); } catch (TimeoutException e) { + Subscription s = subRef.get(); + if (s != null) { + s.cancel(); + } throw new IllegalStateException( "MongoDB did not respond within " + SETUP_TIMEOUT_SECONDS + "s", e); } catch (ExecutionException e) { @@ -466,11 +572,14 @@ public void onComplete() { } /** - * Rejects a subscription whose {@link EventType} does not match the configured mode. + * Rejects a subscription whose {@link EventType} does not match the configured + * mode. *

* In SINGLE_CHANNEL mode every event type shares one collection, so only the - * {@code ALL_SINGLE_CHANNEL} subscription — the one {@code BaseStoreFactory} opens — - * is meaningful; a per-type subscription would silently watch the shared collection + * {@code ALL_SINGLE_CHANNEL} subscription — the one {@code BaseStoreFactory} + * opens — + * is meaningful; a per-type subscription would silently watch the shared + * collection * and receive unrelated event types. Mirrors {@code RedisStreamEventStore}. */ private void validateSubscribe(EventType type) { @@ -493,7 +602,23 @@ private String getCollectionName(EventType type) { return collectionPrefix + type.name(); } - /** Logs a failed insert; a published event is never retried, as in the Kafka store. */ + private MongoCollection getCollection(String collectionName) { + return collectionCache.computeIfAbsent(collectionName, name -> { + MongoCollection col = database.getCollection(name); + if (writeConcern != null) { + col = col.withWriteConcern(writeConcern); + } + if (readPreference != null) { + col = col.withReadPreference(readPreference); + } + return col; + }); + } + + /** + * Logs a failed insert; a published event is never retried, as in the Kafka + * store. + */ private static final class PublishSubscriber implements Subscriber { private final EventType type; @@ -526,12 +651,15 @@ public void onComplete() { } /** - * Delivers change events to one listener and reopens the stream when it ends, which + * Delivers change events to one listener and reopens the stream when it ends, + * which * replaces the reconnect loop a blocking cursor needed. */ - private final class ChangeSubscriber + final class ChangeSubscriber implements Subscriber> { + private static final long DEMAND_BATCH_SIZE = 128; + private final MongoCollection collection; private final EventType type; private final WatcherHandle handle; @@ -539,10 +667,10 @@ private final class ChangeSubscriber private final Class clazz; ChangeSubscriber(MongoCollection collection, - EventType type, - WatcherHandle handle, - EventListener listener, - Class clazz) { + EventType type, + WatcherHandle handle, + EventListener listener, + Class clazz) { this.collection = collection; this.type = type; this.handle = handle; @@ -553,13 +681,24 @@ private final class ChangeSubscriber @Override public void onSubscribe(Subscription subscription) { handle.setSubscription(subscription); - subscription.request(Long.MAX_VALUE); + subscription.request(DEMAND_BATCH_SIZE); } @Override public void onNext(ChangeStreamDocument change) { + if (handle.stopped.get()) { + return; + } + + handle.resetReconnectAttempts(); handle.setResumeToken(change.getResumeToken()); + // Replenish demand to maintain backpressure flow control + Subscription sub = handle.getSubscription(); + if (sub != null && !handle.stopped.get()) { + sub.request(1); + } + Document doc = change.getFullDocument(); if (doc == null) { return; @@ -567,7 +706,8 @@ public void onNext(ChangeStreamDocument change) { // A node sees its own inserts too, so drop them on the document's own nodeId // rather than paying for the deserialization first. - if (nodeId.equals(doc.get("nodeId"))) { + Object rawNodeId = doc.get("nodeId"); + if (rawNodeId instanceof Number && nodeId.longValue() == ((Number) rawNodeId).longValue()) { return; } @@ -585,9 +725,12 @@ public void onNext(ChangeStreamDocument change) { try { T event = MAPPER.readValue(payload, clazz); + if (handle.stopped.get()) { + return; + } // Belt and braces: a document written without the nodeId field still gets // filtered on the value carried by the event itself. - if (!nodeId.equals(event.getNodeId())) { + if (event.getNodeId() == null || nodeId.longValue() != event.getNodeId().longValue()) { listener.onMessage(event); } } catch (Exception e) { @@ -601,7 +744,14 @@ public void onError(Throwable error) { handle.markTerminated(); return; } - log.warn("Change stream on {} failed, reopening...", collection.getNamespace(), error); + + if (isChangeStreamHistoryLost(error)) { + log.error("Change stream history lost on {}. Clearing resume point and restarting from current time.", + collection.getNamespace(), error); + handle.clearResumePoint(); + } else { + log.warn("Change stream on {} failed, reopening...", collection.getNamespace(), error); + } reopen(); } @@ -616,25 +766,62 @@ public void onComplete() { } private void reopen() { + int attempts = handle.incrementReconnectAttempts(); + long delay = Math.min( + INITIAL_REOPEN_DELAY_MILLIS * (1L << Math.min(attempts - 1, 4)), + MAX_REOPEN_DELAY_MILLIS); try { watcherExecutor.schedule( () -> watch(collection, type, handle, listener, clazz), - REOPEN_DELAY_MILLIS, TimeUnit.MILLISECONDS); + delay, TimeUnit.MILLISECONDS); } catch (RejectedExecutionException e) { log.debug("Not reopening the change stream on {}, the store is shutting down", collection.getNamespace()); } } + + private boolean isChangeStreamHistoryLost(Throwable error) { + Throwable curr = error; + while (curr != null) { + if (curr instanceof MongoCommandException) { + int code = ((MongoCommandException) curr).getErrorCode(); + // 286 = ChangeStreamHistoryLost, 40585 = ChangeStreamFatalError, 40579 = + // ChangeStreamTimedOut + if (code == 286 || code == 40585 || code == 40579) { + return true; + } + } + String msg = curr.getMessage(); + if (msg != null && (msg.contains("history lost") || msg.contains("ChangeStreamHistoryLost") + || msg.contains("resume point may no longer be in the oplog"))) { + return true; + } + curr = curr.getCause(); + } + return false; + } } - private static final class WatcherHandle { + static final class WatcherHandle { + + private static final Subscription CANCELLED = new Subscription() { + @Override + public void request(long n) { + } + + @Override + public void cancel() { + } + }; final AtomicBoolean stopped = new AtomicBoolean(false); private final CountDownLatch terminated = new CountDownLatch(1); - private final BsonTimestamp startAt; - private Subscription subscription; - private BsonDocument resumeToken; + private final AtomicReference subRef = new AtomicReference<>(); + private final AtomicInteger reconnectAttempts = new AtomicInteger(0); + + private volatile BsonTimestamp startAt; + private volatile BsonDocument resumeToken; WatcherHandle(BsonTimestamp startAt) { this.startAt = startAt; @@ -644,36 +831,69 @@ BsonTimestamp startAt() { return startAt; } + void clearResumePoint() { + this.resumeToken = null; + this.startAt = null; + } + + int incrementReconnectAttempts() { + return reconnectAttempts.incrementAndGet(); + } + + void resetReconnectAttempts() { + reconnectAttempts.set(0); + } + /** - * Publishes the subscription so {@link #stop()} can cancel it. If stop already - * happened, cancels it right away — otherwise the stream would stay open with - * nobody left to close it. + * Atomically publishes the subscription so {@link #stop()} can cancel it. + * If stop already happened, cancels the subscription immediately. */ - synchronized void setSubscription(Subscription subscription) { - if (stopped.get()) { - subscription.cancel(); - return; + void setSubscription(Subscription subscription) { + while (!stopped.get()) { + Subscription current = subRef.get(); + if (current == CANCELLED) { + subscription.cancel(); + return; + } + if (subRef.compareAndSet(current, subscription)) { + if (stopped.get()) { + if (subRef.compareAndSet(subscription, CANCELLED)) { + subscription.cancel(); + } + } + return; + } } - this.subscription = subscription; + subscription.cancel(); } - synchronized void setResumeToken(BsonDocument resumeToken) { + Subscription getSubscription() { + Subscription s = subRef.get(); + if (s == CANCELLED) { + return null; + } + return s; + } + + void setResumeToken(BsonDocument resumeToken) { this.resumeToken = resumeToken; } - synchronized BsonDocument resumeToken() { + BsonDocument resumeToken() { return resumeToken; } - synchronized void stop() { + void stop() { stopped.set(true); - if (subscription != null) { - subscription.cancel(); - subscription = null; - } else { - // Nothing was ever opened, so there is nothing left to wait for. - terminated.countDown(); + Subscription s = subRef.getAndSet(CANCELLED); + if (s != null && s != CANCELLED) { + try { + s.cancel(); + } catch (Exception e) { + log.debug("Error cancelling change stream subscription", e); + } } + terminated.countDown(); } void markTerminated() { @@ -697,6 +917,8 @@ public static final class Builder { private EventStoreMode eventStoreMode = EventStoreMode.MULTI_CHANNEL; private String collectionPrefix; private long ttlSeconds = DEFAULT_TTL_SECONDS; + private WriteConcern writeConcern; + private ReadPreference readPreference; /** * Creates a new builder. @@ -737,6 +959,29 @@ public Builder ttlSeconds(long ttlSeconds) { return this; } + /** + * Sets the write concern to use for publishing events. + * + * @param writeConcern write concern (e.g. WriteConcern.W1 or UNACKNOWLEDGED) + * @return this builder + */ + public Builder writeConcern(@NotNull WriteConcern writeConcern) { + this.writeConcern = Objects.requireNonNull(writeConcern, "writeConcern"); + return this; + } + + /** + * Sets the read preference for change stream cursors. + * + * @param readPreference read preference (e.g. + * ReadPreference.secondaryPreferred()) + * @return this builder + */ + public Builder readPreference(@NotNull ReadPreference readPreference) { + this.readPreference = Objects.requireNonNull(readPreference, "readPreference"); + return this; + } + public MongoEventStore build() { return new MongoEventStore( mongoClient, @@ -744,8 +989,9 @@ public MongoEventStore build() { eventStoreMode, nodeId, collectionPrefix, - ttlSeconds - ); + ttlSeconds, + writeConcern, + readPreference); } } } diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/interop/DistributedMongoJsClientInteropTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/interop/DistributedMongoJsClientInteropTest.java new file mode 100644 index 00000000..39361f9c --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/interop/DistributedMongoJsClientInteropTest.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.integration.interop; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.parallel.ResourceLock; + +import com.mongodb.reactivestreams.client.MongoClient; +import com.socketio4j.socketio.Configuration; +import com.socketio4j.socketio.SocketIOServer; +import com.socketio4j.socketio.TestResourceCleanup; +import com.socketio4j.socketio.integration.cluster.DistributedClusterIntegrationSupport; +import com.socketio4j.socketio.store.container.CustomizedMongoContainer; +import com.socketio4j.socketio.store.event.EventStoreMode; +import com.socketio4j.socketio.store.memory.MemoryStoreFactory; +import com.socketio4j.socketio.store.mongo.MongoEventStore; + +/** + * Multi-Node JS Client Interoperability Test Suite backed by MongoDB Change Streams. + */ +@ResourceLock("EMBEDDED_MONGO") +@DisplayName("Multi-Node Official JS Client Interoperability Suite (MongoDB Change Streams)") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class DistributedMongoJsClientInteropTest extends AbstractDistributedJsClientInteropTest { + + @SuppressWarnings("resource") + private static final CustomizedMongoContainer MONGO_CONTAINER = new CustomizedMongoContainer(); + + private static final String DB_NAME = "socketio_js_interop"; + + private MongoClient mc1; + private MongoClient mc2; + private MongoEventStore store1; + private MongoEventStore store2; + + @BeforeAll + @Override + public void setupCluster() throws Exception { + if (!MONGO_CONTAINER.isRunning()) { + for (int attempt = 1; attempt <= 3; attempt++) { + try { + MONGO_CONTAINER.start(); + break; + } catch (Exception e) { + if (attempt == 3) { + throw new RuntimeException("Failed to start MongoDB container", e); + } + try { + Thread.sleep(500); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while starting MongoDB test container", error); + } + } + } + } + + // Server 1 + mc1 = MONGO_CONTAINER.createClient(); + store1 = new MongoEventStore.Builder(mc1, DB_NAME) + .eventStoreMode(EventStoreMode.MULTI_CHANNEL) + .collectionPrefix("interop_events_") + .build(); + Configuration cfg1 = new Configuration(); + DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg1); + cfg1.setHostname("127.0.0.1"); + cfg1.setPort(DistributedClusterIntegrationSupport.findAvailablePort()); + cfg1.setStoreFactory(new MemoryStoreFactory(store1)); + node1 = new SocketIOServer(cfg1); + attachDefaultRoomListeners(node1); + node1.start(); + port1 = cfg1.getPort(); + + // Server 2 + mc2 = MONGO_CONTAINER.createClient(); + store2 = new MongoEventStore.Builder(mc2, DB_NAME) + .eventStoreMode(EventStoreMode.MULTI_CHANNEL) + .collectionPrefix("interop_events_") + .build(); + Configuration cfg2 = new Configuration(); + DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg2); + cfg2.setHostname("127.0.0.1"); + cfg2.setPort(DistributedClusterIntegrationSupport.findAvailablePort()); + cfg2.setStoreFactory(new MemoryStoreFactory(store2)); + node2 = new SocketIOServer(cfg2); + attachDefaultRoomListeners(node2); + node2.start(); + port2 = cfg2.getPort(); + + initJsScript(); + } + + @AfterAll + @Override + public void teardownCluster() { + TestResourceCleanup.runAll("MongoDB distributed interop cleanup", + () -> { if (node1 != null) node1.stop(); }, + () -> { if (node2 != null) node2.stop(); }, + () -> { if (store1 != null) store1.shutdown(); }, + () -> { if (store2 != null) store2.shutdown(); }, + () -> { if (mc1 != null) mc1.close(); }, + () -> { if (mc2 != null) mc2.close(); }, + () -> { if (MONGO_CONTAINER != null && MONGO_CONTAINER.isRunning()) MONGO_CONTAINER.stop(); }); + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/event/MongoPubSubEventStoreTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/event/MongoPubSubEventStoreTest.java new file mode 100644 index 00000000..c6e89297 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/event/MongoPubSubEventStoreTest.java @@ -0,0 +1,164 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.store.event; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.testcontainers.containers.GenericContainer; + +import com.mongodb.reactivestreams.client.MongoClient; +import com.socketio4j.socketio.protocol.Packet; +import com.socketio4j.socketio.protocol.PacketType; +import com.socketio4j.socketio.store.container.CustomizedMongoContainer; +import com.socketio4j.socketio.store.mongo.MongoEventStore; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test class for MongoEventStore using testcontainers. + */ +@ResourceLock("EMBEDDED_MONGO") +public class MongoPubSubEventStoreTest extends AbstractEventStoreTest { + + private static final String DB_NAME = "socketio_event_store_test"; + + private final AtomicInteger testCounter = new AtomicInteger(); + private MongoClient sharedClient; + private String currentPrefix; + + @BeforeEach + @Override + public void setUp() throws Exception { + currentPrefix = "test_" + testCounter.incrementAndGet() + "_"; + super.setUp(); + } + + @Override + protected GenericContainer createContainer() { + return new CustomizedMongoContainer().withReuse(false); + } + + private synchronized MongoClient getSharedClient() { + if (sharedClient == null) { + CustomizedMongoContainer mongoContainer = (CustomizedMongoContainer) container; + sharedClient = mongoContainer.createClient(); + } + return sharedClient; + } + + @Override + protected EventStore createEventStore(Long nodeId) throws Exception { + return new MongoEventStore.Builder(getSharedClient(), DB_NAME) + .nodeId(nodeId) + .collectionPrefix(currentPrefix) + .eventStoreMode(EventStoreMode.MULTI_CHANNEL) + .build(); + } + + @Override + protected void closeClients() { + // Shared client remains open across tests to prevent reconnection overhead and cursor race + } + + @AfterAll + @Override + public void stopContainer() { + if (sharedClient != null) { + try { + sharedClient.close(); + } catch (Exception ignored) { + } + sharedClient = null; + } + super.stopContainer(); + } + + @Test + public void testSingleChannelPubSub() throws Exception { + String singlePrefix = "single_" + testCounter.incrementAndGet() + "_"; + + MongoEventStore singlePubStore = new MongoEventStore.Builder(getSharedClient(), DB_NAME) + .nodeId(300L) + .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) + .collectionPrefix(singlePrefix) + .build(); + + MongoEventStore singleSubStore = new MongoEventStore.Builder(getSharedClient(), DB_NAME) + .nodeId(301L) + .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) + .collectionPrefix(singlePrefix) + .build(); + + try { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedRef = new AtomicReference<>(); + + singleSubStore.subscribe( + EventType.ALL_SINGLE_CHANNEL, + message -> { + if (message instanceof DispatchMessage) { + receivedRef.set((DispatchMessage) message); + latch.countDown(); + } + }, + DispatchMessage.class + ); + + Packet packet = new Packet(PacketType.MESSAGE); + packet.setSubType(PacketType.EVENT); + packet.setName("single-channel-event"); + packet.setNsp("/"); + packet.setData("hello-single-channel"); + + DispatchMessage outgoing = new DispatchMessage("roomA", packet, "/"); + outgoing.setNodeId(300L); + + singlePubStore.publish(EventType.DISPATCH, outgoing); + + assertTrue(latch.await(5, TimeUnit.SECONDS), "Message should be received in SINGLE_CHANNEL mode"); + DispatchMessage received = receivedRef.get(); + assertNotNull(received); + assertEquals("roomA", received.getRoom()); + assertEquals(300L, received.getNodeId()); + assertEquals("single-channel-event", received.getPacket().getName()); + assertEquals("hello-single-channel", received.getPacket().getData()); + + // Unsubscribe test + singleSubStore.unsubscribe(EventType.ALL_SINGLE_CHANNEL); + CountDownLatch unsubLatch = new CountDownLatch(1); + AtomicReference unsubReceived = new AtomicReference<>(); + + singlePubStore.publish(EventType.DISPATCH, outgoing); + assertFalse(unsubLatch.await(2, TimeUnit.SECONDS), "No message after unsubscribe"); + assertNull(unsubReceived.get()); + } finally { + singlePubStore.shutdown(); + singleSubStore.shutdown(); + } + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoEventStoreTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoEventStoreTest.java new file mode 100644 index 00000000..e32bcdf5 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoEventStoreTest.java @@ -0,0 +1,395 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.store.mongo; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.bson.BsonDocument; +import org.bson.BsonTimestamp; +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscription; + +import com.mongodb.MongoCommandException; +import com.mongodb.MongoNamespace; +import com.mongodb.ReadPreference; +import com.mongodb.WriteConcern; +import com.mongodb.client.model.changestream.ChangeStreamDocument; +import com.mongodb.client.result.InsertOneResult; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoCollection; +import com.mongodb.reactivestreams.client.MongoDatabase; +import com.socketio4j.socketio.protocol.Packet; +import com.socketio4j.socketio.protocol.PacketType; +import com.socketio4j.socketio.store.event.DispatchMessage; +import com.socketio4j.socketio.store.event.EventListener; +import com.socketio4j.socketio.store.event.EventMessage; +import com.socketio4j.socketio.store.event.EventMessageJsonSupport; +import com.socketio4j.socketio.store.event.EventStoreMode; +import com.socketio4j.socketio.store.event.EventStoreType; +import com.socketio4j.socketio.store.event.EventType; +import com.socketio4j.socketio.store.event.PublishMode; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MongoEventStoreTest { + + private MongoClient mongoClient; + private MongoDatabase mongoDatabase; + private MongoCollection mongoCollection; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + mongoClient = mock(MongoClient.class); + mongoDatabase = mock(MongoDatabase.class); + mongoCollection = mock(MongoCollection.class); + when(mongoClient.getDatabase(anyString())).thenReturn(mongoDatabase); + } + + @Test + void testBuilderDefaultsAndCustomValues() { + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(42L) + .collectionPrefix("custom_events_") + .ttlSeconds(120) + .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) + .writeConcern(WriteConcern.W1) + .readPreference(ReadPreference.secondaryPreferred()) + .build(); + + assertEquals(EventStoreMode.SINGLE_CHANNEL, store.getEventStoreMode()); + assertEquals(EventStoreType.PUBSUB, store.getEventStoreType()); + assertEquals(PublishMode.UNRELIABLE, store.getPublishMode()); + assertEquals("custom_events_", store.getCollectionPrefix()); + assertEquals(120, store.getTtlSeconds()); + assertEquals(WriteConcern.W1, store.getWriteConcern()); + assertEquals(ReadPreference.secondaryPreferred(), store.getReadPreference()); + } + + @Test + void testValidateSubscribeModes() { + MongoEventStore singleChannelStore = new MongoEventStore.Builder(mongoClient, "testdb") + .eventStoreMode(EventStoreMode.SINGLE_CHANNEL) + .build(); + + assertThrows(UnsupportedOperationException.class, + () -> singleChannelStore.subscribe0(EventType.DISPATCH, msg -> { + }, EventMessage.class)); + + MongoEventStore multiChannelStore = new MongoEventStore.Builder(mongoClient, "testdb") + .eventStoreMode(EventStoreMode.MULTI_CHANNEL) + .build(); + + assertThrows(UnsupportedOperationException.class, + () -> multiChannelStore.subscribe0(EventType.ALL_SINGLE_CHANNEL, msg -> { + }, EventMessage.class)); + } + + @Test + void testWatcherHandleLifecycleAndCancel() { + BsonTimestamp startAt = new BsonTimestamp(100, 1); + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(startAt); + + assertEquals(startAt, handle.startAt()); + assertNull(handle.resumeToken()); + + BsonDocument token = new BsonDocument(); + handle.setResumeToken(token); + assertEquals(token, handle.resumeToken()); + + handle.clearResumePoint(); + assertNull(handle.resumeToken()); + assertNull(handle.startAt()); + + AtomicBoolean cancelled = new AtomicBoolean(false); + Subscription subscription = new Subscription() { + @Override + public void request(long n) { + } + + @Override + public void cancel() { + cancelled.set(true); + } + }; + + handle.setSubscription(subscription); + assertEquals(subscription, handle.getSubscription()); + + handle.stop(); + assertTrue(cancelled.get()); + assertTrue(handle.stopped.get()); + assertNull(handle.getSubscription()); + } + + @Test + void testFastShutdownWithoutArtificialDelay() { + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .build(); + + long start = System.currentTimeMillis(); + store.shutdown0(); + long elapsed = System.currentTimeMillis() - start; + + // Shutdown should complete promptly, well below the previous 1000ms artificial + // delay + assertTrue(elapsed < 800, "Shutdown took " + elapsed + "ms, expected under 800ms"); + } + + @Test + void testPublishValidation() { + when(mongoDatabase.getCollection(anyString())).thenReturn(mongoCollection); + Publisher dummyPublisher = subscriber -> subscriber + .onSubscribe(mock(Subscription.class)); + when(mongoCollection.insertOne(any(Document.class))).thenReturn(dummyPublisher); + + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(10L) + .build(); + + EventMessage msg = new EventMessage() { + @Override + public String getType() { + return "DISPATCH"; + } + }; + + store.publish0(EventType.DISPATCH, msg); + assertEquals(10L, msg.getNodeId()); + } + + @Test + void testOplogHistoryLostClearsResumePoint() { + when(mongoCollection.getNamespace()).thenReturn(new MongoNamespace("testdb.events")); + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(1L) + .build(); + + BsonTimestamp startAt = new BsonTimestamp(100, 1); + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(startAt); + handle.setResumeToken(new BsonDocument()); + + MongoEventStore.ChangeSubscriber subscriber = store.new ChangeSubscriber<>( + mongoCollection, EventType.DISPATCH, handle, msg -> { + }, DispatchMessage.class); + + // Simulate MongoCommandException with error code 286 (ChangeStreamHistoryLost) + MongoCommandException historyLostException = mock(MongoCommandException.class); + when(historyLostException.getErrorCode()).thenReturn(286); + when(historyLostException.getMessage()) + .thenReturn("ChangeStreamHistoryLost: resume point no longer in oplog"); + + subscriber.onError(historyLostException); + + // Resume point must be cleared so the next watch() starts at current time + assertNull(handle.resumeToken()); + assertNull(handle.startAt()); + + store.shutdown0(); + } + + @Test + void testTransientErrorPreservesResumePoint() { + when(mongoCollection.getNamespace()).thenReturn(new MongoNamespace("testdb.events")); + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(1L) + .build(); + + BsonTimestamp startAt = new BsonTimestamp(100, 1); + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(startAt); + BsonDocument token = new BsonDocument(); + handle.setResumeToken(token); + + MongoEventStore.ChangeSubscriber subscriber = store.new ChangeSubscriber<>( + mongoCollection, EventType.DISPATCH, handle, msg -> { + }, DispatchMessage.class); + + // Simulate transient network exception + subscriber.onError(new RuntimeException("Transient connection reset")); + + // Resume token must be preserved to resume after the last delivered event + assertEquals(token, handle.resumeToken()); + + store.shutdown0(); + } + + @Test + @SuppressWarnings("unchecked") + void testChangeSubscriberDropsSelfPublishedEvents() { + when(mongoCollection.getNamespace()).thenReturn(new MongoNamespace("testdb.events")); + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(50L) + .build(); + + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(new BsonTimestamp(1, 1)); + EventListener listener = mock(EventListener.class); + + MongoEventStore.ChangeSubscriber subscriber = store.new ChangeSubscriber<>( + mongoCollection, EventType.DISPATCH, handle, listener, DispatchMessage.class); + + ChangeStreamDocument change = mock(ChangeStreamDocument.class); + Document doc = new Document() + .append("nodeId", 50L) // same node id + .append("eventType", "DISPATCH") + .append("payload", + "{\"room\":\"r\",\"namespace\":\"/\",\"packet\":{\"type\":2,\"data\":\"hi\"}}"); + + when(change.getFullDocument()).thenReturn(doc); + when(change.getResumeToken()).thenReturn(new BsonDocument()); + + subscriber.onNext(change); + + // Must drop without calling listener + verify(listener, never()).onMessage(any()); + + store.shutdown0(); + } + + @Test + void testChangeSubscriberDeliversAndPreservesBinaryPayload() { + when(mongoCollection.getNamespace()).thenReturn(new MongoNamespace("testdb.events")); + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(50L) + .build(); + + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(new BsonTimestamp(1, 1)); + AtomicReference receivedRef = new AtomicReference<>(); + + MongoEventStore.ChangeSubscriber subscriber = store.new ChangeSubscriber<>( + mongoCollection, EventType.DISPATCH, handle, receivedRef::set, DispatchMessage.class); + + byte[] rawBytes = new byte[] { 0x10, 0x20, 0x30, 0x40, 0x50 }; + Packet packet = new Packet(PacketType.BINARY_EVENT); + packet.setName("bin-event"); + packet.setNsp("/binary"); + packet.setData(rawBytes); + + DispatchMessage msg = new DispatchMessage("binRoom", packet, "/binary"); + msg.setNodeId(99L); // remote node + + // Use store's serializer logic + String json; + try { + json = EventMessageJsonSupport.createObjectMapper().writeValueAsString(msg); + } catch (Exception e) { + throw new RuntimeException(e); + } + + Document doc = new Document() + .append("nodeId", 99L) + .append("eventType", "DISPATCH") + .append("payload", json); + + @SuppressWarnings("unchecked") + ChangeStreamDocument change = mock(ChangeStreamDocument.class); + when(change.getFullDocument()).thenReturn(doc); + when(change.getResumeToken()).thenReturn(new BsonDocument()); + + subscriber.onNext(change); + + DispatchMessage received = receivedRef.get(); + assertNotNull(received); + assertEquals(99L, received.getNodeId()); + assertEquals("binRoom", received.getRoom()); + assertNotNull(received.getPacket()); + assertArrayEquals(rawBytes, (byte[]) received.getPacket().getData()); + + store.shutdown0(); + } + + @Test + void testPublishAndSubscribeAfterShutdown() { + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(1L) + .build(); + + store.shutdown0(); + + // Second shutdown must be safe and idempotent + assertDoesNotThrow(store::shutdown0); + + // Publish after shutdown should safely no-op without exception + assertDoesNotThrow(() -> store.publish0(EventType.DISPATCH, new DispatchMessage())); + + // Subscribe after shutdown must be rejected + assertThrows(IllegalStateException.class, () -> store.subscribe0(EventType.DISPATCH, msg -> { + }, DispatchMessage.class)); + } + + @Test + void testReconnectAttemptsPersistAcrossReopens() { + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(new BsonTimestamp(1, 1)); + + assertEquals(1, handle.incrementReconnectAttempts()); + assertEquals(2, handle.incrementReconnectAttempts()); + assertEquals(3, handle.incrementReconnectAttempts()); + + handle.resetReconnectAttempts(); + assertEquals(1, handle.incrementReconnectAttempts()); + } + + @Test + @SuppressWarnings("unchecked") + void testStoppedWatcherHandleDropsInFlightOnNext() { + when(mongoCollection.getNamespace()).thenReturn(new MongoNamespace("testdb.events")); + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(50L) + .build(); + + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(new BsonTimestamp(1, 1)); + EventListener listener = mock(EventListener.class); + + MongoEventStore.ChangeSubscriber subscriber = store.new ChangeSubscriber<>( + mongoCollection, EventType.DISPATCH, handle, listener, DispatchMessage.class); + + // Stop handle before event arrives + handle.stop(); + + ChangeStreamDocument change = mock(ChangeStreamDocument.class); + Document doc = new Document() + .append("nodeId", 99L) + .append("eventType", "DISPATCH") + .append("payload", + "{\"room\":\"r\",\"namespace\":\"/\",\"packet\":{\"type\":2,\"data\":\"hi\"}}"); + + when(change.getFullDocument()).thenReturn(doc); + when(change.getResumeToken()).thenReturn(new BsonDocument()); + + subscriber.onNext(change); + + // Must drop without calling listener because handle is stopped + verify(listener, never()).onMessage(any()); + + store.shutdown0(); + } +} From b371b3a1fb7f5983ce6d29b8b7d75b872356b990 Mon Sep 17 00:00:00 2001 From: sanjomo Date: Sat, 19 Sep 2026 11:21:34 +0530 Subject: [PATCH 18/18] Add Mongo-backed async event store This change introduces a MongoStoreFactory for MongoDB-backed clustered session storage and updates MongoEventStore to use asynchronous change stream startup, TTL index setup, and shutdown handling. It also catches listener exceptions and detects unsupported standalone MongoDB change stream deployments. Cluster integration tests are switched to the Mongo-backed store factory, and coverage is added for async subscribe/shutdown behavior. --- .../socketio/store/mongo/MongoEventStore.java | 388 ++++++++++-------- .../store/mongo/MongoStoreFactory.java | 124 ++++++ .../cluster/DistributedMongoClusterTest.java | 4 +- .../store/mongo/MongoEventStoreTest.java | 94 +++++ .../store/mongo/MongoStoreFactoryTest.java | 105 +++++ 5 files changed, 552 insertions(+), 163 deletions(-) create mode 100644 netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoStoreFactory.java create mode 100644 netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoStoreFactoryTest.java diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java index 9999ab8d..a410b8be 100644 --- a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoEventStore.java @@ -29,12 +29,10 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -45,7 +43,6 @@ import org.bson.conversions.Bson; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.slf4j.Logger; @@ -53,7 +50,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mongodb.MongoCommandException; -import com.mongodb.MongoException; import com.mongodb.ReadPreference; import com.mongodb.WriteConcern; import com.mongodb.client.model.Aggregates; @@ -83,13 +79,11 @@ * (MULTI_CHANNEL) * or all events go into one collection (SINGLE_CHANNEL). *

- * Built on the reactive streams driver: {@code publish0} is called from the - * Netty - * event loop, so the insert is handed to the driver and never waited on. Only - * the - * one-off setup done while subscribing (index creation, reading the cluster - * time) - * blocks, and that runs on the thread starting the server. + * Built on the reactive streams driver: fully non-blocking and asynchronous. + * Both {@code publish0} and {@code subscribe0} (and {@code subscribeAsync}) + * are entirely non-blocking. TTL index creation, reconciliation, and change stream + * initialization run asynchronously via Reactive Streams subscribers without blocking + * the calling threads. *

* A TTL index is created on each collection to automatically expire documents * after a configurable retention period (default 60 seconds), preventing @@ -104,11 +98,6 @@ public class MongoEventStore implements EventStore { private static final String DEFAULT_COLLECTION_PREFIX = "socketio_events_"; private static final long DEFAULT_TTL_SECONDS = 60; - /** - * How long a blocking setup command may take before subscribing is given up on. - */ - private static final long SETUP_TIMEOUT_SECONDS = 10; - /** Initial delay before reopening a change stream that ended or failed. */ private static final long INITIAL_REOPEN_DELAY_MILLIS = 500; @@ -299,13 +288,35 @@ public void subscribe0( EventType type, final EventListener listener, Class clazz) { + if (!running.get()) { + throw new IllegalStateException("MongoEventStore has been shut down"); + } + subscribeAsync(type, listener, clazz); + } + + /** + * Asynchronously subscribes to the specified event type without blocking. + *

+ * Initiates asynchronous TTL index creation, captures the cluster operation time + * asynchronously, and opens the change stream cursor. + * + * @param type event type to subscribe to + * @param listener listener for received messages + * @param clazz event message class + * @param message type + * @return a {@link CompletableFuture} that completes when the change stream subscription is established + */ + public CompletableFuture subscribeAsync( + EventType type, + final EventListener listener, + Class clazz) { if (!running.get()) { throw new IllegalStateException("MongoEventStore has been shut down"); } - Objects.requireNonNull(listener); - Objects.requireNonNull(clazz); + Objects.requireNonNull(listener, "listener cannot be null"); + Objects.requireNonNull(clazz, "clazz cannot be null"); validateSubscribe(type); @@ -313,22 +324,12 @@ public void subscribe0( MongoCollection collection = getCollection(collectionName); if (indexedCollections.add(collectionName)) { - ensureTtlIndex(collection); - } - - // The change stream opens asynchronously, so events published in between would - // be - // lost. Starting it at the operation time read here covers that window instead - // of - // making subscribe0 wait for a cursor it cannot observe. - WatcherHandle handle = new WatcherHandle(currentOperationTime()); - - // Register before opening the stream, and in a single atomic map operation: - // with a - // separate computeIfAbsent + add, an unsubscribe0 dropping the queue in between - // would leave the watcher running but unregistered, still delivering events - // after - // unsubscribe. + ensureTtlIndexAsync(collection, collectionName); + } + + WatcherHandle handle = new WatcherHandle(null); + + // Register before opening the stream in a single atomic map operation watchers.compute(type, (k, queue) -> { Queue q = queue; if (q == null) { @@ -338,15 +339,16 @@ public void subscribe0( return q; }); - watch(collection, type, handle, listener, clazz); + CompletableFuture subscriptionFuture = new CompletableFuture<>(); + startWatcherAsync(collection, type, handle, listener, clazz, subscriptionFuture); + return subscriptionFuture; } /** * Builds the server-side Change Stream aggregation pipeline. *

* Filters for inserts only (ignoring TTL deletes) and drops self-published - * events on - * the MongoDB server before network transmission. + * events on the MongoDB server before network transmission. */ private List createPipeline() { return Collections.singletonList( @@ -356,17 +358,79 @@ private List createPipeline() { } /** - * Opens the change stream, resuming after the last delivered event when this is - * a - * reopen, and otherwise starting at the operation time captured while - * subscribing. + * Starts the watcher asynchronously by retrieving the server's operation time via ping + * and opening the change stream at that point without blocking the calling thread. + */ + private void startWatcherAsync( + MongoCollection collection, + EventType type, + WatcherHandle handle, + EventListener listener, + Class clazz, + CompletableFuture subscriptionFuture) { + + if (handle.stopped.get()) { + subscriptionFuture.complete(null); + return; + } + + database.runCommand(new Document("ping", 1)).subscribe(new Subscriber() { + private BsonTimestamp opTime; + + @Override + public void onSubscribe(Subscription s) { + s.request(1); + } + + @Override + public void onNext(Document doc) { + if (doc != null) { + Object operationTime = doc.get("operationTime"); + if (operationTime instanceof BsonTimestamp) { + opTime = (BsonTimestamp) operationTime; + } + } + } + + @Override + public void onError(Throwable t) { + log.debug("Ping command failed to retrieve operationTime on {}, starting watch with server default: {}", + collection.getNamespace(), t.getMessage()); + if (!handle.stopped.get()) { + watch(collection, type, handle, listener, clazz, subscriptionFuture); + } else { + subscriptionFuture.complete(null); + } + } + + @Override + public void onComplete() { + if (handle.stopped.get()) { + subscriptionFuture.complete(null); + return; + } + if (opTime != null) { + handle.setStartAt(opTime); + } + watch(collection, type, handle, listener, clazz, subscriptionFuture); + } + }); + } + + /** + * Opens the change stream, resuming after the last delivered event when this is a + * reopen, and otherwise starting at the operation time captured while subscribing. */ private void watch(MongoCollection collection, EventType type, WatcherHandle handle, EventListener listener, - Class clazz) { + Class clazz, + @Nullable CompletableFuture subscriptionFuture) { if (handle.stopped.get()) { + if (subscriptionFuture != null && !subscriptionFuture.isDone()) { + subscriptionFuture.complete(null); + } return; } @@ -378,7 +442,7 @@ private void watch(MongoCollection collection stream = stream.startAtOperationTime(handle.startAt()); } - stream.subscribe(new ChangeSubscriber(collection, type, handle, listener, clazz)); + stream.subscribe(new ChangeSubscriber(collection, type, handle, listener, clazz, subscriptionFuture)); } @Override @@ -394,8 +458,17 @@ public void unsubscribe0(EventType type) { @Override public void shutdown0() { + shutdownAsync().join(); + } + + /** + * Asynchronously shuts down the Mongo event store. + * + * @return a {@link CompletableFuture} completing when all watchers are cancelled and executor is stopped + */ + public CompletableFuture shutdownAsync() { if (!running.compareAndSet(true, false)) { - return; + return CompletableFuture.completedFuture(null); } List cancelled = new ArrayList(); @@ -405,24 +478,25 @@ public void shutdown0() { Arrays.stream(EventType.values()).forEach(this::unsubscribe); watchers.clear(); - awaitCancellation(cancelled); - watcherExecutor.shutdown(); - try { - if (!watcherExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + return CompletableFuture.runAsync(() -> { + awaitCancellation(cancelled); + + watcherExecutor.shutdown(); + try { + if (!watcherExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + watcherExecutor.shutdownNow(); + } + } catch (InterruptedException e) { watcherExecutor.shutdownNow(); + Thread.currentThread().interrupt(); } - } catch (InterruptedException e) { - watcherExecutor.shutdownNow(); - Thread.currentThread().interrupt(); - } + }); } /** - * Waits for the change streams cancelled by {@link #shutdown0()} to end, so - * that a - * caller closing its {@code MongoClient} right afterwards does not interrupt - * them + * Waits for the change streams cancelled by {@link #shutdown0()} to end, so that a + * caller closing its {@code MongoClient} right afterwards does not interrupt them * mid-flight. */ private void awaitCancellation(List handles) { @@ -445,130 +519,71 @@ private void awaitCancellation(List handles) { } /** - * Creates or reconciles the TTL index used to expire published events. + * Creates or reconciles the TTL index used to expire published events asynchronously. *

- * {@code createIndex} creates the collection when it does not exist yet and is - * a - * no-op for an identical index, but it never updates an existing - * {@code createdAt} - * index whose {@code expireAfterSeconds} differs — it fails with an - * index-options - * conflict and leaves the old retention period in place. That conflict is - * caught - * here and the TTL is changed in place with {@code collMod}. - *

- * Any other failure (missing privileges, an unsupported server) aborts the - * subscription: without the index, published events never expire and the - * collection - * grows without bound, which is an operational problem an operator must see - * rather - * than find later in a full database. + * {@code createIndex} creates the collection when it does not exist yet and is a + * no-op for an identical index. If an index already exists with a different retention period, + * it catches {@code INDEX_OPTIONS_CONFLICT} (error code 85) and asynchronously reconciles + * the TTL using {@code collMod}. */ - private void ensureTtlIndex(MongoCollection collection) { - try { - await(collection.createIndex( - Indexes.ascending("createdAt"), - new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS))); - } catch (MongoCommandException e) { - if (e.getErrorCode() != INDEX_OPTIONS_CONFLICT) { - throw ttlIndexFailure(collection, e); - } - try { - await(database.runCommand( - new Document("collMod", collection.getNamespace().getCollectionName()) - .append("index", new Document("keyPattern", new Document("createdAt", 1)) - .append("expireAfterSeconds", ttlSeconds)))); - log.info("Updated TTL index on {} to {} seconds", - collection.getNamespace(), ttlSeconds); - } catch (MongoException ce) { - throw ttlIndexFailure(collection, ce); - } - } catch (MongoException e) { - throw ttlIndexFailure(collection, e); - } - } + private void ensureTtlIndexAsync(MongoCollection collection, String collectionName) { + collection.createIndex( + Indexes.ascending("createdAt"), + new IndexOptions().expireAfter(ttlSeconds, TimeUnit.SECONDS)) + .subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + s.request(1); + } - private IllegalStateException ttlIndexFailure(MongoCollection collection, Exception cause) { - return new IllegalStateException("Failed to apply the TTL index of " + ttlSeconds - + "s on " + collection.getNamespace() - + "; published events would never expire", cause); - } + @Override + public void onNext(String indexName) { + log.debug("Ensured TTL index {} on {}", indexName, collection.getNamespace()); + } - /** - * Reads the server's current operation time, used as the change stream start - * point. - * Returns {@code null} when the deployment does not report one, in which case - * the - * stream simply starts at whatever the server considers now. - */ - private BsonTimestamp currentOperationTime() { - Document result = await(database.runCommand(new Document("ping", 1))); - if (result == null) { - return null; - } - Object operationTime = result.get("operationTime"); - if (operationTime instanceof BsonTimestamp) { - return (BsonTimestamp) operationTime; - } - return null; + @Override + public void onError(Throwable t) { + if (t instanceof MongoCommandException + && ((MongoCommandException) t).getErrorCode() == INDEX_OPTIONS_CONFLICT) { + reconcileTtlIndexAsync(collection, collectionName); + } else { + indexedCollections.remove(collectionName); + log.error("Failed to apply TTL index of {}s on {}", ttlSeconds, collection.getNamespace(), t); + } + } + + @Override + public void onComplete() { + } + }); } - /** - * Subscribes to a one-shot publisher and waits for it, so the setup done while - * subscribing keeps its ordering and its failures. Never called from the event - * loop. - */ - private static T await(Publisher publisher) { - final CompletableFuture future = new CompletableFuture(); - final AtomicReference subRef = new AtomicReference(); - publisher.subscribe(new Subscriber() { - private T value; + private void reconcileTtlIndexAsync(MongoCollection collection, String collectionName) { + Document collModCmd = new Document("collMod", collection.getNamespace().getCollectionName()) + .append("index", new Document("keyPattern", new Document("createdAt", 1)) + .append("expireAfterSeconds", ttlSeconds)); + database.runCommand(collModCmd).subscribe(new Subscriber() { @Override - public void onSubscribe(Subscription subscription) { - subRef.set(subscription); - subscription.request(1); + public void onSubscribe(Subscription s) { + s.request(1); } @Override - public void onNext(T item) { - value = item; + public void onNext(Document doc) { + log.info("Updated TTL index on {} to {} seconds via collMod", collection.getNamespace(), ttlSeconds); } @Override - public void onError(Throwable error) { - future.completeExceptionally(error); + public void onError(Throwable t) { + indexedCollections.remove(collectionName); + log.error("Failed to reconcile TTL index on {}", collection.getNamespace(), t); } @Override public void onComplete() { - future.complete(value); } }); - - try { - return future.get(SETUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Subscription s = subRef.get(); - if (s != null) { - s.cancel(); - } - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while waiting for MongoDB", e); - } catch (TimeoutException e) { - Subscription s = subRef.get(); - if (s != null) { - s.cancel(); - } - throw new IllegalStateException( - "MongoDB did not respond within " + SETUP_TIMEOUT_SECONDS + "s", e); - } catch (ExecutionException e) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException) { - throw (RuntimeException) cause; - } - throw new IllegalStateException("MongoDB command failed", cause); - } } /** @@ -666,22 +681,37 @@ final class ChangeSubscriber private final EventListener listener; private final Class clazz; + private final CompletableFuture initialFuture; + ChangeSubscriber(MongoCollection collection, EventType type, WatcherHandle handle, EventListener listener, Class clazz) { + this(collection, type, handle, listener, clazz, null); + } + + ChangeSubscriber(MongoCollection collection, + EventType type, + WatcherHandle handle, + EventListener listener, + Class clazz, + @Nullable CompletableFuture initialFuture) { this.collection = collection; this.type = type; this.handle = handle; this.listener = listener; this.clazz = clazz; + this.initialFuture = initialFuture; } @Override public void onSubscribe(Subscription subscription) { handle.setSubscription(subscription); subscription.request(DEMAND_BATCH_SIZE); + if (initialFuture != null && !initialFuture.isDone()) { + initialFuture.complete(null); + } } @Override @@ -742,13 +772,24 @@ public void onNext(ChangeStreamDocument change) { public void onError(Throwable error) { if (handle.stopped.get()) { handle.markTerminated(); + if (initialFuture != null && !initialFuture.isDone()) { + initialFuture.complete(null); + } return; } + if (initialFuture != null && !initialFuture.isDone()) { + initialFuture.completeExceptionally(error); + } + if (isChangeStreamHistoryLost(error)) { log.error("Change stream history lost on {}. Clearing resume point and restarting from current time.", collection.getNamespace(), error); handle.clearResumePoint(); + } else if (isStandaloneError(error)) { + log.error("MongoDB change streams require a replica set or sharded cluster. " + + "Standalone deployments are not supported on {}: {}", + collection.getNamespace(), error.getMessage()); } else { log.warn("Change stream on {} failed, reopening...", collection.getNamespace(), error); } @@ -772,7 +813,7 @@ private void reopen() { MAX_REOPEN_DELAY_MILLIS); try { watcherExecutor.schedule( - () -> watch(collection, type, handle, listener, clazz), + () -> watch(collection, type, handle, listener, clazz, null), delay, TimeUnit.MILLISECONDS); } catch (RejectedExecutionException e) { log.debug("Not reopening the change stream on {}, the store is shutting down", @@ -800,6 +841,27 @@ private boolean isChangeStreamHistoryLost(Throwable error) { } return false; } + + private boolean isStandaloneError(Throwable error) { + Throwable curr = error; + while (curr != null) { + if (curr instanceof MongoCommandException) { + int code = ((MongoCommandException) curr).getErrorCode(); + // 40573: The $changeStream stage is only supported on replica sets + if (code == 40573) { + return true; + } + } + String msg = curr.getMessage(); + if (msg != null && (msg.contains("only supported on replica sets") + || msg.contains("Change streams are only supported on replica sets") + || msg.contains("The $changeStream stage is only supported on replica sets"))) { + return true; + } + curr = curr.getCause(); + } + return false; + } } static final class WatcherHandle { @@ -831,6 +893,10 @@ BsonTimestamp startAt() { return startAt; } + void setStartAt(BsonTimestamp startAt) { + this.startAt = startAt; + } + void clearResumePoint() { this.resumeToken = null; this.startAt = null; diff --git a/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoStoreFactory.java b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoStoreFactory.java new file mode 100644 index 00000000..e46b1b11 --- /dev/null +++ b/netty-socketio-core/src/main/java/com/socketio4j/socketio/store/mongo/MongoStoreFactory.java @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.store.mongo; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.mongodb.reactivestreams.client.MongoClient; +import com.socketio4j.socketio.store.Store; +import com.socketio4j.socketio.store.event.BaseStoreFactory; +import com.socketio4j.socketio.store.event.EventStore; +import com.socketio4j.socketio.store.event.EventStoreMode; +import com.socketio4j.socketio.store.memory.MemoryStore; + +/** + * A {@code StoreFactory} implementation that provides session-scoped storage + * and MongoDB Change Streams based event distribution. + *

+ * Session data is stored in memory via {@link MemoryStore} for low-latency, + * non-blocking access, while event propagation across cluster nodes is handled + * asynchronously by {@link MongoEventStore} using MongoDB Change Streams. + */ +public class MongoStoreFactory extends BaseStoreFactory { + + private static final Logger log = LoggerFactory.getLogger(MongoStoreFactory.class); + + private final EventStore eventStore; + + /** + * Creates a {@code MongoStoreFactory} using default {@link MongoEventStore} + * with {@link EventStoreMode#MULTI_CHANNEL} mode. + * + * @param mongoClient shared MongoDB client (must connect to a replica set) + * @param databaseName database to use for event collections + */ + public MongoStoreFactory(@NotNull MongoClient mongoClient, @NotNull String databaseName) { + this(mongoClient, databaseName, EventStoreMode.MULTI_CHANNEL); + } + + /** + * Creates a {@code MongoStoreFactory} using default {@link MongoEventStore} + * with the specified {@link EventStoreMode}. + * + * @param mongoClient shared MongoDB client (must connect to a replica set) + * @param databaseName database to use for event collections + * @param eventStoreMode SINGLE_CHANNEL or MULTI_CHANNEL mode + */ + public MongoStoreFactory(@NotNull MongoClient mongoClient, + @NotNull String databaseName, + @Nullable EventStoreMode eventStoreMode) { + this(createDefaultEventStore(mongoClient, databaseName, eventStoreMode)); + } + + private static EventStore createDefaultEventStore(MongoClient mongoClient, + String databaseName, + EventStoreMode mode) { + EventStoreMode targetMode = EventStoreMode.MULTI_CHANNEL; + if (mode != null) { + targetMode = mode; + } + return new MongoEventStore.Builder(mongoClient, databaseName) + .eventStoreMode(targetMode) + .build(); + } + + /** + * Creates a {@code MongoStoreFactory} using the provided {@link EventStore}. + * + * @param eventStore non-null event store implementation + */ + public MongoStoreFactory(@NotNull EventStore eventStore) { + this.eventStore = Objects.requireNonNull(eventStore, "eventStore cannot be null"); + } + + @Override + public Store createStore(UUID sessionId) { + return new MemoryStore(); + } + + @Override + public EventStore eventStore() { + return eventStore; + } + + @Override + public Map createMap(String name) { + return new ConcurrentHashMap<>(); + } + + @Override + public void shutdown() { + try { + eventStore.shutdown(); + } catch (Exception e) { + log.error("Failed to shut down Mongo event store", e); + } + } + + @Override + public String toString() { + return getClass().getSimpleName() + " (memory session store, MongoDB change streams publish/subscribe)"; + } +} diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java index 2465a9e2..93d64bea 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/integration/cluster/DistributedMongoClusterTest.java @@ -30,8 +30,8 @@ import com.socketio4j.socketio.SocketIOServer; import com.socketio4j.socketio.store.container.CustomizedMongoContainer; import com.socketio4j.socketio.store.event.EventStoreMode; -import com.socketio4j.socketio.store.memory.MemoryStoreFactory; import com.socketio4j.socketio.store.mongo.MongoEventStore; +import com.socketio4j.socketio.store.mongo.MongoStoreFactory; /** * Runs {@link DistributedCommonTest} against all MongoDB-backed cluster variants while sharing @@ -79,7 +79,7 @@ private static SocketIOServer startNode(MongoEventStore store, Configuration cfg DistributedClusterIntegrationSupport.applyReuseListenAddress(cfg); cfg.setHostname("127.0.0.1"); cfg.setPort(0); - cfg.setStoreFactory(new MemoryStoreFactory(store)); + cfg.setStoreFactory(new MongoStoreFactory(store)); SocketIOServer node = new SocketIOServer(cfg); DistributedClusterIntegrationSupport.attachDefaultRoomListeners(node); diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoEventStoreTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoEventStoreTest.java index e32bcdf5..05ab3544 100644 --- a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoEventStoreTest.java +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoEventStoreTest.java @@ -392,4 +392,98 @@ void testStoppedWatcherHandleDropsInFlightOnNext() { store.shutdown0(); } + + @Test + @SuppressWarnings("unchecked") + void testChangeSubscriberCatchesExceptionFromListener() { + when(mongoCollection.getNamespace()).thenReturn(new MongoNamespace("testdb.events")); + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(50L) + .build(); + + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(new BsonTimestamp(1, 1)); + EventListener listener = mock(EventListener.class); + org.mockito.Mockito.doThrow(new RuntimeException("Error in listener")).when(listener).onMessage(any()); + + MongoEventStore.ChangeSubscriber subscriber = store.new ChangeSubscriber<>( + mongoCollection, EventType.DISPATCH, handle, listener, DispatchMessage.class); + + ChangeStreamDocument change = mock(ChangeStreamDocument.class); + Document doc = new Document() + .append("nodeId", 99L) + .append("eventType", "DISPATCH") + .append("payload", + "{\"room\":\"r\",\"namespace\":\"/\",\"packet\":{\"type\":2,\"data\":\"hi\"}}"); + + when(change.getFullDocument()).thenReturn(doc); + when(change.getResumeToken()).thenReturn(new BsonDocument()); + + // Should safely catch Throwable without propagating or failing + assertDoesNotThrow(() -> subscriber.onNext(change)); + + store.shutdown0(); + } + + @Test + void testStandaloneErrorDetectionInChangeSubscriber() { + when(mongoCollection.getNamespace()).thenReturn(new MongoNamespace("testdb.events")); + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(1L) + .build(); + + MongoEventStore.WatcherHandle handle = new MongoEventStore.WatcherHandle(new BsonTimestamp(1, 1)); + MongoEventStore.ChangeSubscriber subscriber = store.new ChangeSubscriber<>( + mongoCollection, EventType.DISPATCH, handle, msg -> { + }, DispatchMessage.class); + + MongoCommandException standaloneEx = mock(MongoCommandException.class); + when(standaloneEx.getErrorCode()).thenReturn(40573); + when(standaloneEx.getMessage()) + .thenReturn("The $changeStream stage is only supported on replica sets"); + + assertDoesNotThrow(() -> subscriber.onError(standaloneEx)); + + store.shutdown0(); + } + + @Test + void testShutdownAsyncCompletesPromptly() { + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .build(); + + java.util.concurrent.CompletableFuture future = store.shutdownAsync(); + assertNotNull(future); + assertDoesNotThrow(() -> future.get(2, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(future.isDone()); + } + + @Test + @SuppressWarnings("unchecked") + void testSubscribe0IsNonBlocking() { + when(mongoDatabase.getCollection(anyString())).thenReturn(mongoCollection); + when(mongoCollection.getNamespace()).thenReturn(new MongoNamespace("testdb.events")); + + // Mock ping publisher that never completes (simulating a slow network) + Publisher slowPingPublisher = subscriber -> { + // intentionally do not invoke onNext or onComplete + }; + when(mongoDatabase.runCommand(any(Document.class))).thenReturn(slowPingPublisher); + + Publisher indexPublisher = subscriber -> subscriber.onSubscribe(mock(Subscription.class)); + when(mongoCollection.createIndex(any(org.bson.conversions.Bson.class), any(com.mongodb.client.model.IndexOptions.class))) + .thenReturn(indexPublisher); + + MongoEventStore store = new MongoEventStore.Builder(mongoClient, "testdb") + .nodeId(1L) + .build(); + + long start = System.currentTimeMillis(); + // subscribe0 must return immediately without waiting for ping or index + store.subscribe0(EventType.DISPATCH, msg -> { + }, DispatchMessage.class); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(elapsed < 200, "subscribe0 blocked for " + elapsed + "ms, expected non-blocking (<200ms)"); + store.shutdown0(); + } } diff --git a/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoStoreFactoryTest.java b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoStoreFactoryTest.java new file mode 100644 index 00000000..f98135e8 --- /dev/null +++ b/netty-socketio-core/src/test/java/com/socketio4j/socketio/store/mongo/MongoStoreFactoryTest.java @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2025 The Socketio4j Project + * Parent project : Copyright (c) 2012-2025 Nikita Koksharov + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.socketio4j.socketio.store.mongo; + +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoDatabase; +import com.socketio4j.socketio.store.Store; +import com.socketio4j.socketio.store.event.EventStore; +import com.socketio4j.socketio.store.event.EventStoreMode; +import com.socketio4j.socketio.store.memory.MemoryStore; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.verify; +import static org.mockito.Mockito.when; + +public class MongoStoreFactoryTest { + + private MongoClient mongoClient; + private MongoDatabase mongoDatabase; + + @BeforeEach + void setUp() { + mongoClient = mock(MongoClient.class); + mongoDatabase = mock(MongoDatabase.class); + when(mongoClient.getDatabase(anyString())).thenReturn(mongoDatabase); + } + + @Test + void testConstructorWithDatabaseName() { + MongoStoreFactory factory = new MongoStoreFactory(mongoClient, "testdb"); + assertNotNull(factory.eventStore()); + assertEquals(EventStoreMode.MULTI_CHANNEL, factory.eventStore().getEventStoreMode()); + } + + @Test + void testConstructorWithMode() { + MongoStoreFactory factory = new MongoStoreFactory(mongoClient, "testdb", EventStoreMode.SINGLE_CHANNEL); + assertNotNull(factory.eventStore()); + assertEquals(EventStoreMode.SINGLE_CHANNEL, factory.eventStore().getEventStoreMode()); + } + + @Test + void testCreateStoreReturnsMemoryStore() { + MongoStoreFactory factory = new MongoStoreFactory(mongoClient, "testdb"); + UUID sessionId = UUID.randomUUID(); + Store store = factory.createStore(sessionId); + assertNotNull(store); + assertInstanceOf(MemoryStore.class, store); + + store.set("key", "value"); + assertEquals("value", store.get("key")); + assertTrue(store.has("key")); + store.del("key"); + store.destroy(); + } + + @Test + void testCreateMap() { + MongoStoreFactory factory = new MongoStoreFactory(mongoClient, "testdb"); + Map map = factory.createMap("myMap"); + assertNotNull(map); + map.put("k", "v"); + assertEquals("v", map.get("k")); + } + + @Test + void testShutdownDelegatesToEventStore() { + EventStore mockEventStore = mock(EventStore.class); + MongoStoreFactory factory = new MongoStoreFactory(mockEventStore); + + factory.shutdown(); + verify(mockEventStore).shutdown(); + } + + @Test + void testToString() { + MongoStoreFactory factory = new MongoStoreFactory(mongoClient, "testdb"); + assertTrue(factory.toString().contains("MongoStoreFactory")); + } +}