Починить межсерверную репликацию личных сообщений

Репликация личных сообщений между серверами теперь работает корректно.
This commit is contained in:
AidarKC
2026-08-25 19:31:38 +04:00
parent 7d929e626e
commit 3a5851939e
21 changed files with 267 additions and 513 deletions
@@ -75,6 +75,35 @@ public final class UserAccessServersCurrentDAO {
return result; return result;
} }
/**
* Возвращает уникальные физические серверы из текущей routing-проекции.
* Используется транспортным пулом: одно WSS-соединение держится на сервер,
* а не на каждого пользователя этого сервера.
*/
public List<UserAccessServerRouteEntry> listDistinctServers() throws SQLException {
String sql = """
SELECT DISTINCT ON (LOWER(server_login))
server_login, server_url
FROM user_access_servers_current
WHERE server_login IS NOT NULL AND BTRIM(server_login) <> ''
AND server_url IS NOT NULL AND BTRIM(server_url) <> ''
ORDER BY LOWER(server_login), server_login, server_url
""";
List<UserAccessServerRouteEntry> result = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin("");
entry.setServerLogin(rs.getString("server_login"));
entry.setServerUrl(rs.getString("server_url"));
result.add(entry);
}
}
return result;
}
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException { private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry(); UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin(rs.getString("user_login")); entry.setUserLogin(rs.getString("user_login"));
@@ -4,6 +4,8 @@ import org.eclipse.jetty.websocket.api.Session;
import shine.db.entities.CurrentUserEntry; import shine.db.entities.CurrentUserEntry;
import shine.db.entities.ActiveSessionEntry; import shine.db.entities.ActiveSessionEntry;
import java.util.List;
/** /**
* ConnectionContext — контекст состояния одного WebSocket-соединения. * ConnectionContext — контекст состояния одного WebSocket-соединения.
* Живёт ровно столько же, сколько живёт подключение. * Живёт ровно столько же, сколько живёт подключение.
@@ -77,6 +79,12 @@ public class ConnectionContext {
*/ */
private Session wsSession; private Session wsSession;
/** Временная server-to-server роль, заявленная через ServerHello. */
private boolean serverConnection;
private String remoteServerLogin;
private int remoteServerProtocolVersion;
private List<String> remoteServerCapabilities = List.of();
// --- WebSocket Session --- // --- WebSocket Session ---
public Session getWsSession() { public Session getWsSession() {
@@ -87,6 +95,20 @@ public class ConnectionContext {
this.wsSession = wsSession; this.wsSession = wsSession;
} }
public boolean isServerConnection() { return serverConnection; }
public void setServerConnection(boolean serverConnection) { this.serverConnection = serverConnection; }
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public int getRemoteServerProtocolVersion() { return remoteServerProtocolVersion; }
public void setRemoteServerProtocolVersion(int value) { this.remoteServerProtocolVersion = value; }
public List<String> getRemoteServerCapabilities() { return remoteServerCapabilities; }
public void setRemoteServerCapabilities(List<String> capabilities) {
this.remoteServerCapabilities = capabilities == null ? List.of() : List.copyOf(capabilities);
}
// --- SolanaUser / ActiveSession --- // --- SolanaUser / ActiveSession ---
public CurrentUserEntry getCurrentUser() { public CurrentUserEntry getCurrentUser() {
@@ -188,6 +210,10 @@ public class ConnectionContext {
authenticationStatus = AUTH_STATUS_NONE; authenticationStatus = AUTH_STATUS_NONE;
wsSession = null; wsSession = null;
serverConnection = false;
remoteServerLogin = null;
remoteServerProtocolVersion = 0;
remoteServerCapabilities = List.of();
} }
@Override @Override
@@ -133,6 +133,7 @@ import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ServerHello_Handler;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request;
@@ -141,6 +142,7 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Request;
import java.util.Map; import java.util.Map;
@@ -232,6 +234,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendSignal", new Net_SendSignal_Handler()), Map.entry("SendSignal", new Net_SendSignal_Handler()),
// --- system --- // --- system ---
Map.entry("ServerHello", new Net_ServerHello_Handler()),
Map.entry("Ping", new Net_Ping_Handler()), Map.entry("Ping", new Net_Ping_Handler()),
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()), Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()), Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()),
@@ -323,6 +326,7 @@ public final class JsonHandlerRegistry {
Map.entry("SendSignal", Net_SendSignal_Request.class), Map.entry("SendSignal", Net_SendSignal_Request.class),
// --- system --- // --- system ---
Map.entry("ServerHello", Net_ServerHello_Request.class),
Map.entry("Ping", Net_Ping_Request.class), Map.entry("Ping", Net_Ping_Request.class),
Map.entry("GetServerInfo", Net_GetServerInfo_Request.class), Map.entry("GetServerInfo", Net_GetServerInfo_Request.class),
Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class), Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class),
@@ -123,7 +123,7 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue; if (remoteLogin.isBlank() || remoteUrl.isBlank()) continue;
if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue; if (!ownServerLogin.isBlank() && remoteLogin.equalsIgnoreCase(ownServerLogin)) continue;
try { try {
REMOTE.upsertUserSetting(remoteUrl, entry, true); REMOTE.upsertUserSetting(remoteLogin, remoteUrl, entry, true);
delivered++; delivered++;
} catch (Exception e) { } catch (Exception e) {
log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e)); log.warn("user_settings immediate sync failed: login={} remoteServer={} reason={}", login, remoteLogin, String.valueOf(e));
@@ -11,27 +11,19 @@ import shine.db.entities.BlockEntry;
import shine.db.entities.SyncServerEntry; import shine.db.entities.SyncServerEntry;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.*; import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
/** /**
* Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers. * Фоновая репликация AddBlock через общий постоянный WSS-пул на серверы
* из локальной таблицы sync_servers.
*/ */
public final class AddBlockSyncService { public final class AddBlockSyncService {
private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class); private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class);
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor( private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
1, 1,
Math.max(2, Runtime.getRuntime().availableProcessors()), Math.max(2, Runtime.getRuntime().availableProcessors()),
@@ -100,14 +92,14 @@ public final class AddBlockSyncService {
} }
private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception { private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception {
String wsUrl = buildWsUrl(partner.getServerAddress()); String wsUrl = ServerConnectionPool.buildWsUrl(partner.getServerAddress());
if (wsUrl == null) { if (wsUrl == null) {
log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}", log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}",
partner.getLogin(), partner.getServerAddress()); partner.getLogin(), partner.getServerAddress());
return; return;
} }
AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock); AddBlockPushResult firstTry = pushBlock(partner, blockchainName, currentBlock);
if (firstTry.ok()) { if (firstTry.ok()) {
log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}", log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}",
partner.getLogin(), blockchainName, blockNumber); partner.getLogin(), blockchainName, blockNumber);
@@ -142,7 +134,7 @@ public final class AddBlockSyncService {
} }
for (BlockEntry blockEntry : missingBlocks) { for (BlockEntry blockEntry : missingBlocks) {
AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry); AddBlockPushResult backfillResult = pushBlock(partner, blockchainName, blockEntry);
if (!backfillResult.ok()) { if (!backfillResult.ok()) {
log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}", log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}",
partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code()); partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code());
@@ -154,8 +146,8 @@ public final class AddBlockSyncService {
partner.getLogin(), blockchainName, fromBlockNumber, blockNumber); partner.getLogin(), blockchainName, fromBlockNumber, blockNumber);
} }
private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception { private AddBlockPushResult pushBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception {
JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry); JsonNode response = sendAddBlock(partner, blockchainName, blockEntry);
int status = response.path("status").asInt(500); int status = response.path("status").asInt(500);
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
return AddBlockPushResult.success(); return AddBlockPushResult.success();
@@ -172,31 +164,16 @@ public final class AddBlockSyncService {
return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash); return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash);
} }
private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception { private JsonNode sendAddBlock(SyncServerEntry partner, String blockchainName, BlockEntry blockEntry) throws Exception {
CompletableFuture<String> responseFuture = new CompletableFuture<>(); String jsonTemplate = buildAddBlockJsonTemplate(blockchainName, blockEntry);
CountDownLatch openLatch = new CountDownLatch(1); return ServerConnectionPool.getInstance().request(
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); partner.getLogin(),
partner.getServerAddress(),
WebSocket webSocket = HTTP.newWebSocketBuilder() jsonTemplate,
.connectTimeout(Duration.ofSeconds(6)) ServerConnectionPool.Priority.BULK);
.buildAsync(URI.create(wsUrl), listener)
.get(8, TimeUnit.SECONDS);
if (!openLatch.await(8, TimeUnit.SECONDS)) {
tryAbort(webSocket);
throw new TimeoutException("WS open timeout");
} }
String requestId = "sync-" + UUID.randomUUID(); private String buildAddBlockJsonTemplate(String blockchainName, BlockEntry blockEntry) throws Exception {
String json = buildAddBlockJson(requestId, blockchainName, blockEntry);
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
tryAbort(webSocket);
return MAPPER.readTree(responseJson);
}
private String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception {
String prevHashHex = blockEntry.getBlockNumber() <= 0 String prevHashHex = blockEntry.getBlockNumber() <= 0
? "" ? ""
: toHex(extractPrevHash32(blockEntry.getBlockBytes())); : toHex(extractPrevHash32(blockEntry.getBlockBytes()));
@@ -205,8 +182,6 @@ public final class AddBlockSyncService {
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName); String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex); String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex);
String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64); String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64);
String safeRequestId = MAPPER.writeValueAsString(requestId);
return """ return """
{ {
"op":"AddBlock", "op":"AddBlock",
@@ -218,7 +193,7 @@ public final class AddBlockSyncService {
"blockBytesB64":%s "blockBytesB64":%s
} }
} }
""".formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes); """.formatted("%s", safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes);
} }
private static byte[] extractPrevHash32(byte[] blockBytes) { private static byte[] extractPrevHash32(byte[] blockBytes) {
@@ -240,32 +215,6 @@ public final class AddBlockSyncService {
return s.isEmpty() ? null : s; return s.isEmpty() ? null : s;
} }
private static String buildWsUrl(String serverAddressRaw) {
String host = normalizeHostLike(serverAddressRaw);
if (host == null) return null;
return "wss://" + host + "/ws";
}
private static String normalizeHostLike(String value) {
if (value == null) return null;
String raw = value.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
return host.trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
String cleaned = raw
.replaceFirst("^[a-zA-Z]+://", "")
.replaceFirst("/.*$", "")
.trim()
.toLowerCase(Locale.ROOT);
return cleaned.isEmpty() ? null : cleaned;
}
}
private static String toHex(byte[] bytes) { private static String toHex(byte[] bytes) {
if (bytes == null) return ""; if (bytes == null) return "";
StringBuilder sb = new StringBuilder(bytes.length * 2); StringBuilder sb = new StringBuilder(bytes.length * 2);
@@ -276,17 +225,6 @@ public final class AddBlockSyncService {
return sb.toString(); return sb.toString();
} }
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
}
private record AddBlockPushResult( private record AddBlockPushResult(
boolean ok, boolean ok,
int status, int status,
@@ -309,52 +247,4 @@ public final class AddBlockSyncService {
} }
} }
private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch;
private final StringBuilder textBuffer = new StringBuilder();
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
this.responseFuture = responseFuture;
this.openLatch = openLatch;
}
@Override
public void onOpen(WebSocket webSocket) {
openLatch.countDown();
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data);
if (last && !responseFuture.isDone()) {
responseFuture.complete(textBuffer.toString());
}
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(error);
}
openLatch.countDown();
}
}
} }
@@ -149,7 +149,8 @@ public final class DmDeliveryCoordinator {
completion.submit(() -> { completion.submit(() -> {
try { try {
if (!routeLogin.equals(ownServerLogin)) { if (!routeLogin.equals(ownServerLogin)) {
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); REMOTE.receiveIncomingMessage(
routeLogin, route.getServerUrl(), incomingBlobB64, ownServerLogin);
} }
return new RouteAttempt(routeLogin, null); return new RouteAttempt(routeLogin, null);
} catch (Exception e) { } catch (Exception e) {
@@ -188,6 +189,7 @@ public final class DmDeliveryCoordinator {
if (incoming == null || outgoing == null) return current; if (incoming == null || outgoing == null) return current;
try { try {
REMOTE.sendMessagePair( REMOTE.sendMessagePair(
peer.getServerLogin(),
peer.getServerUrl(), peer.getServerUrl(),
Base64.getEncoder().encodeToString(incoming.getRawBlock()), Base64.getEncoder().encodeToString(incoming.getRawBlock()),
Base64.getEncoder().encodeToString(outgoing.getRawBlock()), Base64.getEncoder().encodeToString(outgoing.getRawBlock()),
@@ -204,7 +206,7 @@ public final class DmDeliveryCoordinator {
if (peer == null) return current; if (peer == null) return current;
try { try {
RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus( RemoteDmSyncClient.RemoteDeliveryStatus remote = REMOTE.getDmDeliveryStatus(
peer.getServerUrl(), current.getOutgoingMessageKey()); peer.getServerLogin(), peer.getServerUrl(), current.getOutgoingMessageKey());
if (remote.known() && remote.delivered()) { if (remote.known() && remote.delivered()) {
return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis()); return DELIVERY_DAO.markDeliveredFromPeer(current.getEventId(), System.currentTimeMillis());
} }
@@ -27,12 +27,15 @@ public final class DmFederationService {
String ownServerLogin = ownServerLogin(); String ownServerLogin = ownServerLogin();
for (UserAccessServerRouteEntry route : senderRoutes.values()) { for (UserAccessServerRouteEntry route : senderRoutes.values()) {
if (isOwnServer(route, ownServerLogin)) continue; if (isOwnServer(route, ownServerLogin)) continue;
REMOTE.sendMessagePair(route.getServerUrl(), incomingBlobB64, outgoingBlobB64, ownServerLogin); REMOTE.sendMessagePair(
route.getServerLogin(), route.getServerUrl(),
incomingBlobB64, outgoingBlobB64, ownServerLogin);
} }
for (UserAccessServerRouteEntry route : recipientRoutes.values()) { for (UserAccessServerRouteEntry route : recipientRoutes.values()) {
if (isOwnServer(route, ownServerLogin)) continue; if (isOwnServer(route, ownServerLogin)) continue;
if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue; if (senderRoutes.containsKey(normalize(route.getServerLogin()))) continue;
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); REMOTE.receiveIncomingMessage(
route.getServerLogin(), route.getServerUrl(), incomingBlobB64, ownServerLogin);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e); log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e);
@@ -54,7 +57,8 @@ public final class DmFederationService {
if (routeLogin == null) continue; if (routeLogin == null) continue;
if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue; if (ownServerLogin != null && ownServerLogin.equalsIgnoreCase(routeLogin)) continue;
if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue; if (normalizedSource != null && normalizedSource.equalsIgnoreCase(routeLogin)) continue;
REMOTE.receiveIncomingMessage(route.getServerUrl(), incomingBlobB64, ownServerLogin); REMOTE.receiveIncomingMessage(
route.getServerLogin(), route.getServerUrl(), incomingBlobB64, ownServerLogin);
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("DM federation incoming relay failed: to={}", toLogin, e); log.warn("DM federation incoming relay failed: to={}", toLogin, e);
@@ -79,9 +83,9 @@ public final class DmFederationService {
for (UserAccessServerRouteEntry route : routes.values()) { for (UserAccessServerRouteEntry route : routes.values()) {
if (isOwnServer(route, ownServerLogin)) continue; if (isOwnServer(route, ownServerLogin)) continue;
if (oneMessageDelete) { if (oneMessageDelete) {
REMOTE.deleteMessage(route.getServerUrl(), blobB64); REMOTE.deleteMessage(route.getServerLogin(), route.getServerUrl(), blobB64);
} else { } else {
REMOTE.deleteConversation(route.getServerUrl(), blobB64); REMOTE.deleteConversation(route.getServerLogin(), route.getServerUrl(), blobB64);
} }
} }
} catch (Exception e) { } catch (Exception e) {
@@ -2,37 +2,22 @@ package server.sync;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** /**
* Минимальный клиент для межсерверных JSON-op запросов по WSS. * Минимальный клиент для межсерверных JSON-op запросов по WSS.
*/ */
public final class RemoteBlockchainSyncClient { public final class RemoteBlockchainSyncClient {
private static final Logger log = LoggerFactory.getLogger(RemoteBlockchainSyncClient.class);
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
public List<RemoteBlockchainHead> listBlockchainHeads(String serverAddressRaw) throws Exception { public List<RemoteBlockchainHead> listBlockchainHeads(String serverAddressRaw) throws Exception {
JsonNode response = send(serverAddressRaw, """ return listBlockchainHeads(null, serverAddressRaw);
}
public List<RemoteBlockchainHead> listBlockchainHeads(String serverLogin, String serverAddressRaw) throws Exception {
JsonNode response = send(serverLogin, serverAddressRaw, """
{ {
"op":"ListBlockchainHeads", "op":"ListBlockchainHeads",
"requestId":%s, "requestId":%s,
@@ -62,8 +47,12 @@ public final class RemoteBlockchainSyncClient {
} }
public RemoteSyncUserProfile getSyncUserProfile(String serverAddressRaw, String login) throws Exception { public RemoteSyncUserProfile getSyncUserProfile(String serverAddressRaw, String login) throws Exception {
return getSyncUserProfile(null, serverAddressRaw, login);
}
public RemoteSyncUserProfile getSyncUserProfile(String serverLogin, String serverAddressRaw, String login) throws Exception {
String safeLogin = MAPPER.writeValueAsString(login); String safeLogin = MAPPER.writeValueAsString(login);
JsonNode response = send(serverAddressRaw, """ JsonNode response = send(serverLogin, serverAddressRaw, """
{ {
"op":"GetSyncUserProfile", "op":"GetSyncUserProfile",
"requestId":%s, "requestId":%s,
@@ -96,9 +85,17 @@ public final class RemoteBlockchainSyncClient {
); );
} }
public RemoteBlockchainBlock getBlockchainBlock(String serverAddressRaw, String blockchainName, int blockNumber) throws Exception { public RemoteBlockchainBlock getBlockchainBlock(
String serverAddressRaw, String blockchainName, int blockNumber
) throws Exception {
return getBlockchainBlock(null, serverAddressRaw, blockchainName, blockNumber);
}
public RemoteBlockchainBlock getBlockchainBlock(
String serverLogin, String serverAddressRaw, String blockchainName, int blockNumber
) throws Exception {
String safeBlockchainName = MAPPER.writeValueAsString(blockchainName); String safeBlockchainName = MAPPER.writeValueAsString(blockchainName);
JsonNode response = send(serverAddressRaw, """ JsonNode response = send(serverLogin, serverAddressRaw, """
{ {
"op":"GetBlockchainBlock", "op":"GetBlockchainBlock",
"requestId":%s, "requestId":%s,
@@ -126,32 +123,12 @@ public final class RemoteBlockchainSyncClient {
); );
} }
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { private JsonNode send(String serverLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("sync-" + UUID.randomUUID()); return ServerConnectionPool.getInstance().request(
String json = jsonTemplate.formatted(requestId); serverLogin,
String wsUrl = buildWsUrl(serverAddressRaw); serverAddressRaw,
if (wsUrl == null) { jsonTemplate,
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); ServerConnectionPool.Priority.BULK);
}
CompletableFuture<String> responseFuture = new CompletableFuture<>();
CountDownLatch openLatch = new CountDownLatch(1);
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
WebSocket webSocket = HTTP.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(6))
.buildAsync(URI.create(wsUrl), listener)
.get(8, TimeUnit.SECONDS);
if (!openLatch.await(8, TimeUnit.SECONDS)) {
tryAbort(webSocket);
throw new TimeoutException("WS open timeout");
}
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
tryAbort(webSocket);
return MAPPER.readTree(responseJson);
} }
private static String errorCode(JsonNode response) { private static String errorCode(JsonNode response) {
@@ -161,40 +138,7 @@ public final class RemoteBlockchainSyncClient {
} }
static String buildWsUrl(String serverAddressRaw) { static String buildWsUrl(String serverAddressRaw) {
String host = normalizeHostLike(serverAddressRaw); return ServerConnectionPool.buildWsUrl(serverAddressRaw);
if (host == null) return null;
return "wss://" + host + "/ws";
}
private static String normalizeHostLike(String value) {
if (value == null) return null;
String raw = value.trim();
if (raw.isEmpty()) return null;
try {
String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw;
URI uri = URI.create(withScheme);
String host = uri.getHost();
if (host == null || host.isBlank()) return null;
return host.trim().toLowerCase(Locale.ROOT);
} catch (Exception e) {
String cleaned = raw
.replaceFirst("^[a-zA-Z]+://", "")
.replaceFirst("/.*$", "")
.trim()
.toLowerCase(Locale.ROOT);
return cleaned.isEmpty() ? null : cleaned;
}
}
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
} }
public record RemoteBlockchainHead( public record RemoteBlockchainHead(
@@ -220,53 +164,4 @@ public final class RemoteBlockchainSyncClient {
long blockchainSizeLimitBytes long blockchainSizeLimitBytes
) {} ) {}
private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch;
private final StringBuilder textBuffer = new StringBuilder();
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
this.responseFuture = responseFuture;
this.openLatch = openLatch;
}
@Override
public void onOpen(WebSocket webSocket) {
openLatch.countDown();
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data);
if (last && !responseFuture.isDone()) {
responseFuture.complete(textBuffer.toString());
}
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
log.warn("Remote sync websocket error: {}", String.valueOf(error));
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(error);
}
openLatch.countDown();
}
}
} }
@@ -3,37 +3,33 @@ package server.sync;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** Клиент стабильных межсерверных DM-операций. */ /** Клиент стабильных межсерверных DM-операций. */
public final class RemoteDmSyncClient { public final class RemoteDmSyncClient {
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
public void sendMessagePair( public void sendMessagePair(
String serverAddressRaw, String serverAddressRaw,
String incomingBlobB64, String incomingBlobB64,
String outgoingBlobB64, String outgoingBlobB64,
String sourceServerLogin String sourceServerLogin
) throws Exception {
sendMessagePair(null, serverAddressRaw, incomingBlobB64, outgoingBlobB64, sourceServerLogin);
}
public void sendMessagePair(
String targetServerLogin,
String serverAddressRaw,
String incomingBlobB64,
String outgoingBlobB64,
String sourceServerLogin
) throws Exception { ) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64); String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(serverAddressRaw, """ JsonNode response = send(targetServerLogin, serverAddressRaw, """
{ {
"op":"ReceiveOutcomingMessage", "op":"ReceiveOutcomingMessage",
"requestId":%s, "requestId":%s,
@@ -50,10 +46,19 @@ public final class RemoteDmSyncClient {
String serverAddressRaw, String serverAddressRaw,
String incomingBlobB64, String incomingBlobB64,
String sourceServerLogin String sourceServerLogin
) throws Exception {
receiveIncomingMessage(null, serverAddressRaw, incomingBlobB64, sourceServerLogin);
}
public void receiveIncomingMessage(
String targetServerLogin,
String serverAddressRaw,
String incomingBlobB64,
String sourceServerLogin
) throws Exception { ) throws Exception {
String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); String incomingJson = MAPPER.writeValueAsString(incomingBlobB64);
String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin); String sourceServerLoginJson = toOptionalJsonField("sourceServerLogin", sourceServerLogin);
JsonNode response = send(serverAddressRaw, """ JsonNode response = send(targetServerLogin, serverAddressRaw, """
{ {
"op":"ReceiveIncomingMessage", "op":"ReceiveIncomingMessage",
"requestId":%s, "requestId":%s,
@@ -66,8 +71,14 @@ public final class RemoteDmSyncClient {
} }
public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception { public RemoteDeliveryStatus getDmDeliveryStatus(String serverAddressRaw, String messageKey) throws Exception {
return getDmDeliveryStatus(null, serverAddressRaw, messageKey);
}
public RemoteDeliveryStatus getDmDeliveryStatus(
String targetServerLogin, String serverAddressRaw, String messageKey
) throws Exception {
String messageKeyJson = MAPPER.writeValueAsString(messageKey); String messageKeyJson = MAPPER.writeValueAsString(messageKey);
JsonNode response = send(serverAddressRaw, """ JsonNode response = send(targetServerLogin, serverAddressRaw, """
{ {
"op":"GetDmDeliveryStatus", "op":"GetDmDeliveryStatus",
"requestId":%s, "requestId":%s,
@@ -94,7 +105,21 @@ public final class RemoteDmSyncClient {
int maxBytes, int maxBytes,
List<String> ackSyncIds List<String> ackSyncIds
) throws Exception { ) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) { return dmSyncBatch(null, serverAddressRaw, ownerLogin, afterStoredAtMs,
afterMessageKey, limit, maxBytes, ackSyncIds);
}
public RemoteDmBatch dmSyncBatch(
String targetServerLogin,
String serverAddressRaw,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes,
List<String> ackSyncIds
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey, return dmSyncBatch(session, ownerLogin, afterStoredAtMs, afterMessageKey,
limit, maxBytes, ackSyncIds); limit, maxBytes, ackSyncIds);
} }
@@ -156,8 +181,12 @@ public final class RemoteDmSyncClient {
} }
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception { public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
deleteMessage(null, serverAddressRaw, blobB64);
}
public void deleteMessage(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64); String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(serverAddressRaw, """ JsonNode response = send(targetServerLogin, serverAddressRaw, """
{ {
"op":"DeleteMessage", "op":"DeleteMessage",
"requestId":%s, "requestId":%s,
@@ -168,8 +197,12 @@ public final class RemoteDmSyncClient {
} }
public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception { public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception {
deleteConversation(null, serverAddressRaw, blobB64);
}
public void deleteConversation(String targetServerLogin, String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64); String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(serverAddressRaw, """ JsonNode response = send(targetServerLogin, serverAddressRaw, """
{ {
"op":"DeleteConversation", "op":"DeleteConversation",
"requestId":%s, "requestId":%s,
@@ -179,27 +212,12 @@ public final class RemoteDmSyncClient {
ensureOk("DeleteConversation", response); ensureOk("DeleteConversation", response);
} }
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { private JsonNode send(String targetServerLogin, String serverAddressRaw, String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("dm-sync-" + UUID.randomUUID()); return ServerConnectionPool.getInstance().request(
String json = jsonTemplate.formatted(requestId); targetServerLogin,
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); serverAddressRaw,
if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); jsonTemplate,
ServerConnectionPool.Priority.REALTIME);
CompletableFuture<String> responseFuture = new CompletableFuture<>();
CountDownLatch openLatch = new CountDownLatch(1);
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
WebSocket webSocket = HTTP.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(6))
.buildAsync(URI.create(wsUrl), listener)
.get(8, TimeUnit.SECONDS);
if (!openLatch.await(8, TimeUnit.SECONDS)) {
tryAbort(webSocket);
throw new TimeoutException("WS open timeout");
}
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
tryAbort(webSocket);
return MAPPER.readTree(responseJson);
} }
private String toOptionalJsonField(String fieldName, String value) throws Exception { private String toOptionalJsonField(String fieldName, String value) throws Exception {
@@ -215,60 +233,10 @@ public final class RemoteDmSyncClient {
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
} }
private static void tryAbort(WebSocket webSocket) {
try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {}
try { webSocket.abort(); } catch (Exception ignored) {}
}
public record RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {} public record RemoteDeliveryStatus(String messageKey, boolean known, boolean delivered) {}
public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List<RemoteDmItem> items) {} public record RemoteDmBatch(long nextStoredAtMs, String nextMessageKey, boolean hasMore, List<RemoteDmItem> items) {}
public record RemoteDmItem( public record RemoteDmItem(
String syncId, String primaryMessageKey, long storedAtMs, List<String> blobsB64 String syncId, String primaryMessageKey, long storedAtMs, List<String> blobsB64
) {} ) {}
private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch;
private final StringBuilder textBuffer = new StringBuilder();
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
this.responseFuture = responseFuture;
this.openLatch = openLatch;
}
@Override
public void onOpen(WebSocket webSocket) {
openLatch.countDown();
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data);
if (last && !responseFuture.isDone()) responseFuture.complete(textBuffer.toString());
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
if (!responseFuture.isDone()) {
responseFuture.completeExceptionally(
new IllegalStateException("WS closed before response: " + statusCode + " " + reason));
}
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!responseFuture.isDone()) responseFuture.completeExceptionally(error);
openLatch.countDown();
}
}
} }
@@ -1,70 +1,35 @@
package server.sync; package server.sync;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI; /**
import java.net.http.HttpClient; * Логический последовательный сеанс поверх общего постоянного WSS-пула.
import java.net.http.WebSocket; * close() больше не закрывает физическое соединение с сервером.
import java.nio.ByteBuffer; */
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** Один последовательный WS-сеанс для синхронизации всех данных access-сервера. */
public final class RemoteSyncSession implements AutoCloseable { public final class RemoteSyncSession implements AutoCloseable {
private static final ObjectMapper MAPPER = new ObjectMapper(); private final String serverLogin;
private static final HttpClient HTTP = HttpClient.newBuilder() private final String serverAddress;
.connectTimeout(Duration.ofSeconds(6)).build();
private final LinkedBlockingQueue<String> responses = new LinkedBlockingQueue<>();
private final WebSocket webSocket;
/** Совместимый конструктор: при отсутствии логина пул использует адрес как ключ peer. */
public RemoteSyncSession(String serverAddressRaw) throws Exception { public RemoteSyncSession(String serverAddressRaw) throws Exception {
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); this(null, serverAddressRaw);
if (wsUrl == null) throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); }
Listener listener = new Listener(responses);
webSocket = HTTP.newWebSocketBuilder().connectTimeout(Duration.ofSeconds(6)) public RemoteSyncSession(String serverLogin, String serverAddressRaw) {
.buildAsync(URI.create(wsUrl), listener).get(8, TimeUnit.SECONDS); this.serverLogin = serverLogin;
this.serverAddress = serverAddressRaw;
} }
public synchronized JsonNode send(String jsonTemplate) throws Exception { public synchronized JsonNode send(String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("access-sync-" + UUID.randomUUID()); return ServerConnectionPool.getInstance().request(
webSocket.sendText(jsonTemplate.formatted(requestId), true).get(8, TimeUnit.SECONDS); serverLogin,
String json = responses.poll(12, TimeUnit.SECONDS); serverAddress,
if (json == null) throw new TimeoutException("WS response timeout"); jsonTemplate,
return MAPPER.readTree(json); ServerConnectionPool.Priority.NORMAL);
} }
@Override @Override
public void close() { public void close() {
try { webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); } catch (Exception ignored) {} // Физическое соединение принадлежит ServerConnectionPool.
}
private static final class Listener implements WebSocket.Listener {
private final LinkedBlockingQueue<String> responses;
private final StringBuilder text = new StringBuilder();
private Listener(LinkedBlockingQueue<String> responses) { this.responses = responses; }
@Override public void onOpen(WebSocket ws) { ws.request(1); }
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
text.append(data);
if (last) {
responses.offer(text.toString());
text.setLength(0);
}
ws.request(1);
return CompletableFuture.completedFuture(null);
}
@Override public CompletionStage<?> onBinary(WebSocket ws, ByteBuffer data, boolean last) {
ws.request(1);
return CompletableFuture.completedFuture(null);
}
@Override public void onError(WebSocket ws, Throwable error) {
responses.offer("{\"status\":500,\"code\":\"WS_ERROR\"}");
}
} }
} }
@@ -4,27 +4,22 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import shine.db.entities.UserSettingEntry; import shine.db.entities.UserSettingEntry;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class RemoteUserSettingsSyncClient { public final class RemoteUserSettingsSyncClient {
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
public void upsertUserSetting(String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery) throws Exception { public void upsertUserSetting(
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) { String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery
) throws Exception {
upsertUserSetting(null, serverAddressRaw, entry, syncDelivery);
}
public void upsertUserSetting(
String targetServerLogin, String serverAddressRaw, UserSettingEntry entry, boolean syncDelivery
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
upsertUserSetting(session, entry, syncDelivery); upsertUserSetting(session, entry, syncDelivery);
} }
} }
@@ -69,7 +64,20 @@ public final class RemoteUserSettingsSyncClient {
int limit, int limit,
int maxBytes int maxBytes
) throws Exception { ) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(serverAddressRaw)) { return userSettingsSyncBatch(null, serverAddressRaw, ownerLogin,
afterTimeMs, afterSettingKey, limit, maxBytes);
}
public RemoteUserSettingsBatch userSettingsSyncBatch(
String targetServerLogin,
String serverAddressRaw,
String ownerLogin,
long afterTimeMs,
String afterSettingKey,
int limit,
int maxBytes
) throws Exception {
try (RemoteSyncSession session = new RemoteSyncSession(targetServerLogin, serverAddressRaw)) {
return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes); return userSettingsSyncBatch(session, ownerLogin, afterTimeMs, afterSettingKey, limit, maxBytes);
} }
} }
@@ -129,34 +137,6 @@ public final class RemoteUserSettingsSyncClient {
); );
} }
private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception {
String requestId = MAPPER.writeValueAsString("user-settings-sync-" + UUID.randomUUID());
String json = jsonTemplate.formatted(requestId);
String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw);
if (wsUrl == null) {
throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw);
}
CompletableFuture<String> responseFuture = new CompletableFuture<>();
CountDownLatch openLatch = new CountDownLatch(1);
SyncWsListener listener = new SyncWsListener(responseFuture, openLatch);
WebSocket webSocket = HTTP.newWebSocketBuilder()
.connectTimeout(Duration.ofSeconds(6))
.buildAsync(URI.create(wsUrl), listener)
.get(8, TimeUnit.SECONDS);
if (!openLatch.await(8, TimeUnit.SECONDS)) {
tryAbort(webSocket);
throw new TimeoutException("WS open timeout");
}
webSocket.sendText(json, true).get(8, TimeUnit.SECONDS);
String responseJson = responseFuture.get(12, TimeUnit.SECONDS);
tryAbort(webSocket);
return MAPPER.readTree(responseJson);
}
private void ensureOk(String op, JsonNode response) { private void ensureOk(String op, JsonNode response) {
int status = response.path("status").asInt(500); int status = response.path("status").asInt(500);
if (status >= 200 && status < 300) return; if (status >= 200 && status < 300) return;
@@ -165,17 +145,6 @@ public final class RemoteUserSettingsSyncClient {
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
} }
private static void tryAbort(WebSocket webSocket) {
try {
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
} catch (Exception ignored) {
}
try {
webSocket.abort();
} catch (Exception ignored) {
}
}
public record RemoteUserSettingsBatch( public record RemoteUserSettingsBatch(
long nextTimeMs, long nextTimeMs,
String nextSettingKey, String nextSettingKey,
@@ -194,35 +163,4 @@ public final class RemoteUserSettingsSyncClient {
String signature String signature
) {} ) {}
private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch;
private final StringBuilder textBuffer = new StringBuilder();
private SyncWsListener(CompletableFuture<String> responseFuture, CountDownLatch openLatch) {
this.responseFuture = responseFuture;
this.openLatch = openLatch;
}
@Override
public void onOpen(WebSocket webSocket) {
openLatch.countDown();
webSocket.request(1);
}
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data);
if (last && !responseFuture.isDone()) {
responseFuture.complete(textBuffer.toString());
}
webSocket.request(1);
return CompletableFuture.completedFuture(null);
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
responseFuture.completeExceptionally(error);
}
}
} }
@@ -67,7 +67,8 @@ public final class BlockchainResyncRecoveryOnStartup {
blockchainName, partnerLogin, partnerAddress); blockchainName, partnerLogin, partnerAddress);
try { try {
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads = REMOTE.listBlockchainHeads(partnerAddress); List<RemoteBlockchainSyncClient.RemoteBlockchainHead> heads =
REMOTE.listBlockchainHeads(partnerLogin, partnerAddress);
RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream() RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream()
.filter(h -> h != null && blockchainName.equals(h.blockchainName())) .filter(h -> h != null && blockchainName.equals(h.blockchainName()))
.findFirst() .findFirst()
@@ -102,7 +102,7 @@ public final class PeriodicBlockchainSyncService {
if (partnerLogin == null) return; if (partnerLogin == null) return;
List<RemoteBlockchainSyncClient.RemoteBlockchainHead> remoteHeads = List<RemoteBlockchainSyncClient.RemoteBlockchainHead> remoteHeads =
REMOTE.listBlockchainHeads(partner.getServerAddress()); REMOTE.listBlockchainHeads(partner.getLogin(), partner.getServerAddress());
for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) { for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) {
if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) { if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) {
@@ -170,7 +170,8 @@ public final class PeriodicBlockchainSyncService {
int fromBlockNumber = Math.max(localLast + 1, 0); int fromBlockNumber = Math.max(localLast + 1, 0);
for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) { for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock = RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
REMOTE.getBlockchainBlock(partner.getServerAddress(), remoteHead.blockchainName(), blockNumber); REMOTE.getBlockchainBlock(
partner.getLogin(), partner.getServerAddress(), remoteHead.blockchainName(), blockNumber);
if (remoteBlock == null) { if (remoteBlock == null) {
log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}", log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}",
partnerLogin, remoteHead.blockchainName(), blockNumber); partnerLogin, remoteHead.blockchainName(), blockNumber);
@@ -284,7 +285,8 @@ public final class PeriodicBlockchainSyncService {
String localPrevHash = ""; String localPrevHash = "";
for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) { for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) {
RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock = RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock =
REMOTE.getBlockchainBlock(partner.getServerAddress(), blockchainName, blockNumber); REMOTE.getBlockchainBlock(
partner.getLogin(), partner.getServerAddress(), blockchainName, blockNumber);
if (remoteBlock == null) { if (remoteBlock == null) {
log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}", log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}",
partnerLogin, blockchainName, blockNumber); partnerLogin, blockchainName, blockNumber);
@@ -137,7 +137,7 @@ public final class PeriodicUserSettingsSyncService {
boolean bootstrapCompleted = false; boolean bootstrapCompleted = false;
int appliedDm; int appliedDm;
try (RemoteSyncSession session = new RemoteSyncSession(route.getServerUrl())) { try (RemoteSyncSession session = new RemoteSyncSession(route.getServerLogin(), route.getServerUrl())) {
for (int page = 0; page < maxPages; page++) { for (int page = 0; page < maxPages; page++) {
RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch( RemoteUserSettingsSyncClient.RemoteUserSettingsBatch batch = REMOTE.userSettingsSyncBatch(
session, session,
@@ -83,3 +83,11 @@ WebSocket-эндпоинт для одного соединения.
- `WsServer` = сервер, который слушает порт и вешает `/ws`. - `WsServer` = сервер, который слушает порт и вешает `/ws`.
- `BlockchainWsEndpoint` = обработчик одного WebSocket-подключения, мост между сетью и логикой. - `BlockchainWsEndpoint` = обработчик одного WebSocket-подключения, мост между сетью и логикой.
## Постоянные server-to-server соединения
Исходящие межсерверные JSON-запросы проходят через
`server.sync.ServerConnectionPool`. Пул держит одно постоянное исходящее WSS-
соединение на `serverLogin`, отправляет `ServerHello`, поддерживает канал через
WebSocket ping/pong и переподключает его с backoff. DM, settings и blockchain
используют это соединение с разными приоритетами, не меняя свою бизнес-логику.
@@ -12,6 +12,7 @@ import server.sync.PeriodicDmSyncService;
import server.sync.PeriodicUserSettingsSyncService; import server.sync.PeriodicUserSettingsSyncService;
import server.sync.SolanaUsersSyncStartupService; import server.sync.SolanaUsersSyncStartupService;
import server.sync.SyncServersBootstrapService; import server.sync.SyncServersBootstrapService;
import server.sync.ServerConnectionPool;
import utils.config.AppConfig; import utils.config.AppConfig;
import java.time.Duration; import java.time.Duration;
@@ -104,6 +105,10 @@ public final class WsServer {
server.start(); server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
ServerConnectionPool.getInstance().startOrLog();
Runtime.getRuntime().addShutdownHook(new Thread(
() -> ServerConnectionPool.getInstance().close(),
"server-connection-pool-shutdown"));
PeriodicDmSyncService.startOrLog(); PeriodicDmSyncService.startOrLog();
PeriodicUserSettingsSyncService.startOrLog(); PeriodicUserSettingsSyncService.startOrLog();
server.join(); server.join();
@@ -41,6 +41,12 @@ dm.worker.dueLimit=100
dm.sync.batchLimit=200 dm.sync.batchLimit=200
dm.sync.batchMaxBytes=3000000 dm.sync.batchMaxBytes=3000000
dm.sync.maxPagesPerPeer=20 dm.sync.maxPagesPerPeer=20
server.pool.pingIdleSeconds=120
server.pool.pongTimeoutSeconds=15
server.pool.requestTimeoutSeconds=12
server.pool.connectTimeoutSeconds=15
server.pool.callerTimeoutSeconds=35
server.pool.maxQueuePerPeer=2000
server.info.url= server.info.url=
server.info.physicalRegion= server.info.physicalRegion=
server.info.description= server.info.description=
+2 -2
View File
@@ -1,3 +1,3 @@
backup.schema.version=2 backup.schema.version=2
backup.full.version=3 backup.full.version=4
last.full.backup.date=2026-08-08 last.full.backup.date=2026-08-25
+2
View File
@@ -32,6 +32,7 @@
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна | | `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
| `Ping` | `05_Technical_Requests_API.md` | keep-alive | | `Ping` | `05_Technical_Requests_API.md` | keep-alive |
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере | | `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов | | `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
@@ -79,6 +80,7 @@
- `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload. - `ReceiveOutcomingMessage` зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`, и сохраняет прежний межсерверный payload.
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее. - Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией.
- Отдельных HTTP endpoints для DM-файлов сейчас нет. - Отдельных HTTP endpoints для DM-файлов сейчас нет.
- Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
+4 -1
View File
@@ -83,7 +83,10 @@
## 5. Синхронизация ## 5. Синхронизация
Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс открывает один последовательный WS-сеанс с peer: сначала синхронизирует настройки, затем забирает `DmSyncBatch`. Настройки и DM имеют раздельные таблицы и правила ACK, но периодический процесс
использует один логический последовательный сеанс поверх постоянного WSS-пула:
сначала синхронизирует настройки, затем забирает `DmSyncBatch`. Завершение
логического сеанса не закрывает физический сокет.
- локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер; - локальная запись создаётся с `synced=false`, если её ещё не подтвердил второй сервер;
- если запись пришла с другого сервера, она сохраняется сразу как `synced=true`; - если запись пришла с другого сервера, она сохраняется сразу как `synced=true`;
+14 -8
View File
@@ -183,16 +183,20 @@ Full resync запускается только тогда, когда:
Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync. Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync.
## 5. Возможное развитие server-to-server транспорта ## 5. Реализованный постоянный server-to-server транспорт
Этот раздел не описывает текущую DM-доставку. DM уже использует короткие one-shot WebSocket-вызовы, indexed outbox и ACK. Ниже остаётся возможное развитие постоянного транспорта и server-auth. Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает
единый постоянный WSS-транспорт, через который выполняются уже существующие операции.
### 5.1 Межсерверное соединение ### 5.1 Межсерверное соединение
- Серверы устанавливают постоянное WebSocket-соединение друг с другом. - Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA. - Адрес партнёра определяется по `server_address` из его Solana PDA.
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA). - После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities.
- При разрыве — переподключение с экспоненциальным backoff. - На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена.
- При разрыве выполняется переподключение с jitter/backoff до 60 секунд.
- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд.
- Один физический канал переиспользуют DM, настройки и blockchain.
### 5.2 Доставка новых данных (push) ### 5.2 Доставка новых данных (push)
@@ -246,19 +250,21 @@ Full resync запускается только тогда, когда:
| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано | | Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано |
| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | | Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано |
| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | | Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано |
| Межсерверный постоянный WebSocket-канал | Нужна реализация | | Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` |
| Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано | | Асинхронная доставка DM на access-серверы получателя | ✅ Реализовано |
| Retry DM до 1 часа + UI-state | ✅ Реализовано | | Retry DM до 1 часа + UI-state | ✅ Реализовано |
| Репликация DM на второй access-сервер по `synced` | ✅ Реализовано | | Репликация DM на второй access-сервер по `synced` | ✅ Реализовано |
| Read-only `GetDmDeliveryStatus` | ✅ Реализовано | | Read-only `GetDmDeliveryStatus` | ✅ Реализовано |
| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия | | Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул |
| Periodic backfill отсутствующего хвоста | ✅ Реализовано | | Periodic backfill отсутствующего хвоста | ✅ Реализовано |
| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync | | Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync |
| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано | | Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано |
| Маршрутизация DM через один/два `access_servers` | ✅ Реализовано | | Маршрутизация DM через один/два `access_servers` | ✅ Реализовано |
| Криптографическая server-to-server авторизация DM | Нужна реализация | | Криптографическая server-to-server авторизация DM | Нужна реализация |
Текущая версия сервера умеет синхронизацию блокчейнов и DM. Постоянные server-to-server соединения не требуются для текущей one-shot WS-реализации; отдельной будущей задачей остаётся криптографическая авторизация DM-вызовов. Текущая версия сервера использует постоянный WSS-пул для существующих
server-to-server JSON-операций. Отдельной будущей задачей остаётся
криптографическая авторизация server-to-server вызовов.
Следующие отдельные шаги после текущего этапа: Следующие отдельные шаги после текущего этапа:
- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов. - отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов.