SHA256
Исправить определение клиентского DM-вызова
This commit is contained in:
+70
@@ -0,0 +1,70 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ServerHello_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Временное server-to-server представление без криптографической проверки.
|
||||
* Заявленный serverLogin принимается на доверии и привязывается к WS-контексту.
|
||||
*/
|
||||
public final class Net_ServerHello_Handler implements JsonMessageHandler {
|
||||
private static final int PROTOCOL_VERSION = 1;
|
||||
private static final List<String> CAPABILITIES = List.of(
|
||||
"dm-sync", "settings-sync", "block-sync", "connection-pool");
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_ServerHello_Request req = (Net_ServerHello_Request) baseRequest;
|
||||
String remoteLogin = normalize(req.getServerLogin());
|
||||
int remoteVersion = req.getProtocolVersion() == null ? 0 : req.getProtocolVersion();
|
||||
if (ctx == null) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req, WireCodes.Status.BAD_REQUEST, "NO_CONNECTION_CONTEXT", "ServerHello требует WebSocket-контекст");
|
||||
}
|
||||
if (remoteLogin == null || remoteVersion <= 0) {
|
||||
return NetExceptionResponseFactory.error(
|
||||
req, WireCodes.Status.BAD_REQUEST, "BAD_SERVER_HELLO", "serverLogin/protocolVersion обязательны");
|
||||
}
|
||||
|
||||
LinkedHashSet<String> unique = new LinkedHashSet<>();
|
||||
if (req.getCapabilities() != null) {
|
||||
for (String capability : req.getCapabilities()) {
|
||||
String normalized = normalize(capability);
|
||||
if (normalized != null && unique.size() < 64) unique.add(normalized);
|
||||
}
|
||||
}
|
||||
ctx.setServerConnection(true);
|
||||
ctx.setRemoteServerLogin(remoteLogin);
|
||||
ctx.setRemoteServerProtocolVersion(remoteVersion);
|
||||
ctx.setRemoteServerCapabilities(new ArrayList<>(unique));
|
||||
|
||||
Net_ServerHello_Response resp = new Net_ServerHello_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setAccepted(true);
|
||||
String localLogin = normalize(AppConfig.getInstance().getParam("server.SHiNE.login"));
|
||||
resp.setServerLogin(localLogin == null ? "" : localLogin);
|
||||
resp.setProtocolVersion(PROTOCOL_VERSION);
|
||||
resp.setCapabilities(CAPABILITIES);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class Net_ServerHello_Request extends Net_Request {
|
||||
private String serverLogin;
|
||||
private Integer protocolVersion;
|
||||
private List<String> capabilities;
|
||||
|
||||
public String getServerLogin() { return serverLogin; }
|
||||
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
|
||||
|
||||
public Integer getProtocolVersion() { return protocolVersion; }
|
||||
public void setProtocolVersion(Integer protocolVersion) { this.protocolVersion = protocolVersion; }
|
||||
|
||||
public List<String> getCapabilities() { return capabilities; }
|
||||
public void setCapabilities(List<String> capabilities) { this.capabilities = capabilities; }
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.system.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class Net_ServerHello_Response extends Net_Response {
|
||||
private boolean accepted;
|
||||
private String serverLogin;
|
||||
private int protocolVersion;
|
||||
private List<String> capabilities;
|
||||
|
||||
public boolean isAccepted() { return accepted; }
|
||||
public void setAccepted(boolean accepted) { this.accepted = accepted; }
|
||||
|
||||
public String getServerLogin() { return serverLogin; }
|
||||
public void setServerLogin(String serverLogin) { this.serverLogin = serverLogin; }
|
||||
|
||||
public int getProtocolVersion() { return protocolVersion; }
|
||||
public void setProtocolVersion(int protocolVersion) { this.protocolVersion = protocolVersion; }
|
||||
|
||||
public List<String> getCapabilities() { return capabilities; }
|
||||
public void setCapabilities(List<String> capabilities) { this.capabilities = capabilities; }
|
||||
}
|
||||
+1
-2
@@ -48,8 +48,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
|
||||
SignedMessageEntry incomingEntry;
|
||||
SignedMessageEntry outgoingEntry;
|
||||
boolean fromPeer = "ReceiveOutcomingMessage".equalsIgnoreCase(req.getOp())
|
||||
|| !isBlank(req.getSourceServerLogin());
|
||||
boolean fromPeer = !isBlank(req.getSourceServerLogin());
|
||||
try {
|
||||
String sourceApi = fromPeer ? "ReceiveOutcomingMessage" : "SendMessagePair";
|
||||
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
|
||||
|
||||
+763
@@ -0,0 +1,763 @@
|
||||
package server.sync;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import shine.db.dao.SyncServersDAO;
|
||||
import shine.db.dao.UserAccessServersCurrentDAO;
|
||||
import shine.db.entities.SyncServerEntry;
|
||||
import shine.db.entities.UserAccessServerRouteEntry;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
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.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.PriorityBlockingQueue;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
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.AtomicLong;
|
||||
|
||||
/**
|
||||
* Общий пул постоянных WSS-соединений между физическими серверами SHiNE.
|
||||
*
|
||||
* <p>Пул меняет только транспорт: существующие JSON-операции, ACK, outbox и
|
||||
* расписания повторов остаются обязанностью вызывающих сервисов.</p>
|
||||
*/
|
||||
public final class ServerConnectionPool implements AutoCloseable {
|
||||
private static final Logger log = LoggerFactory.getLogger(ServerConnectionPool.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
|
||||
private static final int REQUEST_WORKERS = Math.max(
|
||||
8, Math.min(32, Runtime.getRuntime().availableProcessors() * 2));
|
||||
private static final int CONNECTION_WORKERS = Math.max(
|
||||
4, Math.min(16, Runtime.getRuntime().availableProcessors()));
|
||||
private static final ServerConnectionPool INSTANCE = new ServerConnectionPool();
|
||||
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.build();
|
||||
private final ConcurrentHashMap<String, PeerConnection> peers = new ConcurrentHashMap<>();
|
||||
private final AtomicBoolean started = new AtomicBoolean(false);
|
||||
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||
private final AtomicLong requestSequence = new AtomicLong();
|
||||
|
||||
private final ThreadPoolExecutor requestExecutor = new ThreadPoolExecutor(
|
||||
REQUEST_WORKERS,
|
||||
REQUEST_WORKERS,
|
||||
60L,
|
||||
TimeUnit.SECONDS,
|
||||
new java.util.concurrent.LinkedBlockingQueue<>(10_000),
|
||||
daemonThreadFactory("server-pool-request"),
|
||||
new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
private final ThreadPoolExecutor connectionExecutor = new ThreadPoolExecutor(
|
||||
CONNECTION_WORKERS,
|
||||
CONNECTION_WORKERS,
|
||||
60L,
|
||||
TimeUnit.SECONDS,
|
||||
new java.util.concurrent.LinkedBlockingQueue<>(10_000),
|
||||
daemonThreadFactory("server-pool-connect"),
|
||||
new ThreadPoolExecutor.DiscardPolicy());
|
||||
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(
|
||||
2, daemonThreadFactory("server-pool-scheduler"));
|
||||
|
||||
private ServerConnectionPool() {}
|
||||
|
||||
public static ServerConnectionPool getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public void startOrLog() {
|
||||
if (closed.get() || !started.compareAndSet(false, true)) return;
|
||||
scheduler.scheduleWithFixedDelay(this::refreshKnownPeersSafe, 0L, 30L, TimeUnit.SECONDS);
|
||||
scheduler.scheduleWithFixedDelay(this::healthCheckSafe, 10L, 10L, TimeUnit.SECONDS);
|
||||
scheduler.scheduleWithFixedDelay(this::logMetricsSafe, 5L, 5L, TimeUnit.MINUTES);
|
||||
log.info("Server connection pool started: adaptive ping={}s pongTimeout={}s",
|
||||
pingIdleSeconds(), pongTimeoutSeconds());
|
||||
}
|
||||
|
||||
public JsonNode request(
|
||||
String serverLogin,
|
||||
String serverAddress,
|
||||
String jsonTemplate,
|
||||
Priority priority
|
||||
) throws Exception {
|
||||
startOrLog();
|
||||
String normalizedLogin = normalizeLogin(serverLogin);
|
||||
if (normalizedLogin == null) {
|
||||
normalizedLogin = loginFromAddress(serverAddress);
|
||||
}
|
||||
if (normalizedLogin == null) {
|
||||
throw new IllegalArgumentException("Server login and address are empty");
|
||||
}
|
||||
PeerConnection peer = registerPeer(normalizedLogin, serverAddress);
|
||||
return peer.request(jsonTemplate, priority == null ? Priority.NORMAL : priority);
|
||||
}
|
||||
|
||||
public PeerConnection registerPeer(String serverLogin, String serverAddress) {
|
||||
String login = normalizeLogin(serverLogin);
|
||||
String wsUrl = buildWsUrl(serverAddress);
|
||||
if (login == null || wsUrl == null) {
|
||||
throw new IllegalArgumentException("Invalid server peer: login=" + serverLogin + " address=" + serverAddress);
|
||||
}
|
||||
PeerConnection peer = peers.computeIfAbsent(login, ignored -> new PeerConnection(login, wsUrl));
|
||||
peer.updateWsUrl(wsUrl);
|
||||
peer.ensureConnectedInBackground(0L);
|
||||
return peer;
|
||||
}
|
||||
|
||||
public List<PeerMetricsSnapshot> snapshotMetrics() {
|
||||
List<PeerMetricsSnapshot> result = new ArrayList<>();
|
||||
for (PeerConnection peer : peers.values()) result.add(peer.snapshot());
|
||||
result.sort(Comparator.comparing(PeerMetricsSnapshot::serverLogin));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private void refreshKnownPeersSafe() {
|
||||
if (closed.get()) return;
|
||||
try {
|
||||
String ownLogin = normalizeLogin(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||
Map<String, String> discovered = new LinkedHashMap<>();
|
||||
for (SyncServerEntry entry : SyncServersDAO.getInstance().listAll()) {
|
||||
if (entry == null) continue;
|
||||
putDiscovered(discovered, ownLogin, entry.getLogin(), entry.getServerAddress());
|
||||
}
|
||||
for (UserAccessServerRouteEntry entry : UserAccessServersCurrentDAO.getInstance().listDistinctServers()) {
|
||||
if (entry == null) continue;
|
||||
putDiscovered(discovered, ownLogin, entry.getServerLogin(), entry.getServerUrl());
|
||||
}
|
||||
for (Map.Entry<String, String> entry : discovered.entrySet()) {
|
||||
try {
|
||||
registerPeer(entry.getKey(), entry.getValue());
|
||||
} catch (Exception e) {
|
||||
log.warn("Server pool peer registration failed: server={} reason={}",
|
||||
entry.getKey(), compactError(e));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Server pool peer refresh failed: {}", compactError(e));
|
||||
}
|
||||
}
|
||||
|
||||
private static void putDiscovered(
|
||||
Map<String, String> discovered,
|
||||
String ownLogin,
|
||||
String serverLogin,
|
||||
String serverAddress
|
||||
) {
|
||||
String login = normalizeLogin(serverLogin);
|
||||
if (login == null || login.equals(ownLogin) || buildWsUrl(serverAddress) == null) return;
|
||||
discovered.put(login, serverAddress);
|
||||
}
|
||||
|
||||
private void healthCheckSafe() {
|
||||
if (closed.get()) return;
|
||||
for (PeerConnection peer : peers.values()) {
|
||||
try {
|
||||
peer.healthCheck();
|
||||
} catch (Exception e) {
|
||||
log.debug("Server pool health check failed: server={} reason={}",
|
||||
peer.serverLogin, compactError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void logMetricsSafe() {
|
||||
if (closed.get() || peers.isEmpty()) return;
|
||||
long connected = peers.values().stream().filter(p -> p.state == ConnectionState.CONNECTED).count();
|
||||
int queued = peers.values().stream().mapToInt(p -> p.queuedCount.get()).sum();
|
||||
long errors = peers.values().stream().mapToLong(p -> p.failedRequests.get()).sum();
|
||||
log.info("Server pool metrics: peers={} connected={} queued={} failedRequests={}",
|
||||
peers.size(), connected, queued, errors);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
for (PeerConnection peer : peers.values()) peer.closeConnection("pool_shutdown");
|
||||
scheduler.shutdownNow();
|
||||
requestExecutor.shutdownNow();
|
||||
connectionExecutor.shutdownNow();
|
||||
peers.clear();
|
||||
}
|
||||
|
||||
public enum Priority {
|
||||
REALTIME(0),
|
||||
NORMAL(1),
|
||||
BULK(2);
|
||||
|
||||
private final int rank;
|
||||
Priority(int rank) { this.rank = rank; }
|
||||
}
|
||||
|
||||
public enum ConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
CLOSED
|
||||
}
|
||||
|
||||
public record PeerMetricsSnapshot(
|
||||
String serverLogin,
|
||||
String wsUrl,
|
||||
ConnectionState state,
|
||||
long connectedAtMs,
|
||||
long lastActivityAtMs,
|
||||
long lastPingAtMs,
|
||||
long lastPongAtMs,
|
||||
long reconnectCount,
|
||||
int queuedRealtime,
|
||||
int queuedNormal,
|
||||
int queuedBulk,
|
||||
long successfulRequests,
|
||||
long failedRequests,
|
||||
long timedOutRequests,
|
||||
String lastError
|
||||
) {}
|
||||
|
||||
public final class PeerConnection {
|
||||
private final String serverLogin;
|
||||
private final PriorityBlockingQueue<QueuedRequest> queue = new PriorityBlockingQueue<>();
|
||||
private final ConcurrentHashMap<String, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
|
||||
private final AtomicBoolean drainScheduled = new AtomicBoolean(false);
|
||||
private final AtomicBoolean reconnectScheduled = new AtomicBoolean(false);
|
||||
private final AtomicInteger queuedCount = new AtomicInteger();
|
||||
private final AtomicInteger reconnectAttempt = new AtomicInteger();
|
||||
private final AtomicLong generation = new AtomicLong();
|
||||
private final AtomicLong reconnectCount = new AtomicLong();
|
||||
private final AtomicLong successfulRequests = new AtomicLong();
|
||||
private final AtomicLong failedRequests = new AtomicLong();
|
||||
private final AtomicLong timedOutRequests = new AtomicLong();
|
||||
private final Object connectLock = new Object();
|
||||
|
||||
private volatile String wsUrl;
|
||||
private volatile ConnectionState state = ConnectionState.DISCONNECTED;
|
||||
private volatile WebSocket webSocket;
|
||||
private volatile CompletableFuture<Void> readiness;
|
||||
private volatile long connectedAtMs;
|
||||
private volatile long lastActivityAtMs = System.currentTimeMillis();
|
||||
private volatile long lastPingAtMs;
|
||||
private volatile long lastPongAtMs;
|
||||
private volatile long pingAwaitedSinceMs;
|
||||
private volatile String lastError = "";
|
||||
|
||||
private PeerConnection(String serverLogin, String wsUrl) {
|
||||
this.serverLogin = serverLogin;
|
||||
this.wsUrl = wsUrl;
|
||||
}
|
||||
|
||||
private JsonNode request(String jsonTemplate, Priority priority) throws Exception {
|
||||
if (jsonTemplate == null || jsonTemplate.isBlank()) {
|
||||
throw new IllegalArgumentException("JSON request template is empty");
|
||||
}
|
||||
int maxQueue = (int) configLong("server.pool.maxQueuePerPeer", 2_000L, 10L, 100_000L);
|
||||
int queued = queuedCount.incrementAndGet();
|
||||
if (queued > maxQueue) {
|
||||
queuedCount.decrementAndGet();
|
||||
throw new IllegalStateException("Server peer queue is full: " + serverLogin);
|
||||
}
|
||||
|
||||
QueuedRequest task = new QueuedRequest(
|
||||
priority,
|
||||
requestSequence.incrementAndGet(),
|
||||
jsonTemplate,
|
||||
new CompletableFuture<>());
|
||||
queue.offer(task);
|
||||
scheduleDrain();
|
||||
|
||||
long timeoutSeconds = configLong("server.pool.callerTimeoutSeconds", 35L, 5L, 120L);
|
||||
try {
|
||||
return task.result.get(timeoutSeconds, TimeUnit.SECONDS);
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
timedOutRequests.incrementAndGet();
|
||||
if (queue.remove(task)) queuedCount.decrementAndGet();
|
||||
throw new TimeoutException("Server pool caller timeout: " + serverLogin);
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleDrain() {
|
||||
if (!drainScheduled.compareAndSet(false, true)) return;
|
||||
requestExecutor.execute(this::drainQueue);
|
||||
}
|
||||
|
||||
private void drainQueue() {
|
||||
try {
|
||||
for (;;) {
|
||||
QueuedRequest task = queue.poll();
|
||||
if (task == null) return;
|
||||
queuedCount.decrementAndGet();
|
||||
if (task.result.isDone()) continue;
|
||||
try {
|
||||
ensureConnectedBlocking();
|
||||
JsonNode response = sendTemplate(task.jsonTemplate)
|
||||
.get(requestTimeoutSeconds(), TimeUnit.SECONDS);
|
||||
successfulRequests.incrementAndGet();
|
||||
task.result.complete(response);
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
timedOutRequests.incrementAndGet();
|
||||
failedRequests.incrementAndGet();
|
||||
task.result.completeExceptionally(
|
||||
new TimeoutException("Server pool response timeout: " + serverLogin));
|
||||
invalidateConnection("response_timeout", e);
|
||||
} catch (Exception e) {
|
||||
failedRequests.incrementAndGet();
|
||||
task.result.completeExceptionally(unwrap(e));
|
||||
if (state != ConnectionState.DISCONNECTED) {
|
||||
invalidateConnection("request_failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
drainScheduled.set(false);
|
||||
if (!queue.isEmpty()) scheduleDrain();
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureConnectedBlocking() throws Exception {
|
||||
CompletableFuture<Void> localReady;
|
||||
synchronized (connectLock) {
|
||||
if (state == ConnectionState.CONNECTED && webSocket != null && !webSocket.isOutputClosed()) return;
|
||||
if (state == ConnectionState.CLOSED || closed.get()) {
|
||||
throw new IllegalStateException("Server connection pool is closed");
|
||||
}
|
||||
if (readiness == null || readiness.isDone()) startConnectLocked();
|
||||
localReady = readiness;
|
||||
}
|
||||
localReady.get(connectAndHelloTimeoutSeconds(), TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void startConnectLocked() {
|
||||
long currentGeneration = generation.incrementAndGet();
|
||||
state = ConnectionState.CONNECTING;
|
||||
lastError = "";
|
||||
CompletableFuture<Void> ready = new CompletableFuture<>();
|
||||
readiness = ready;
|
||||
Listener listener = new Listener(this, currentGeneration);
|
||||
|
||||
http.newWebSocketBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.buildAsync(URI.create(wsUrl), listener)
|
||||
.thenCompose(ws -> {
|
||||
if (generation.get() != currentGeneration) {
|
||||
try { ws.abort(); } catch (Exception ignored) {}
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Superseded server connection"));
|
||||
}
|
||||
webSocket = ws;
|
||||
return sendServerHello(ws);
|
||||
})
|
||||
.whenComplete((ignored, error) -> {
|
||||
if (error != null) {
|
||||
ready.completeExceptionally(unwrap(error));
|
||||
invalidateConnection(currentGeneration, "connect_or_hello_failed", error);
|
||||
return;
|
||||
}
|
||||
if (generation.get() != currentGeneration) {
|
||||
ready.completeExceptionally(new IllegalStateException("Superseded server connection"));
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
connectedAtMs = now;
|
||||
lastActivityAtMs = now;
|
||||
lastPongAtMs = now;
|
||||
pingAwaitedSinceMs = 0L;
|
||||
reconnectAttempt.set(0);
|
||||
state = ConnectionState.CONNECTED;
|
||||
ready.complete(null);
|
||||
log.info("Server pool connected: server={} url={}", serverLogin, wsUrl);
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> sendServerHello(WebSocket ws) {
|
||||
try {
|
||||
String ownLogin = normalizeLogin(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
|
||||
if (ownLogin == null) ownLogin = "unconfigured-server";
|
||||
String requestId = "server-hello-" + UUID.randomUUID();
|
||||
CompletableFuture<JsonNode> response = new CompletableFuture<>();
|
||||
pending.put(requestId, response);
|
||||
String json = """
|
||||
{
|
||||
"op":"ServerHello",
|
||||
"requestId":%s,
|
||||
"payload":{
|
||||
"serverLogin":%s,
|
||||
"protocolVersion":1,
|
||||
"capabilities":["dm-sync","settings-sync","block-sync","connection-pool"]
|
||||
}
|
||||
}
|
||||
""".formatted(
|
||||
MAPPER.writeValueAsString(requestId),
|
||||
MAPPER.writeValueAsString(ownLogin));
|
||||
lastActivityAtMs = System.currentTimeMillis();
|
||||
ws.sendText(json, true).whenComplete((ignored, error) -> {
|
||||
if (error != null) {
|
||||
pending.remove(requestId);
|
||||
response.completeExceptionally(error);
|
||||
}
|
||||
});
|
||||
return response.orTimeout(requestTimeoutSeconds(), TimeUnit.SECONDS)
|
||||
.thenAccept(node -> ensureOk("ServerHello", node));
|
||||
} catch (Exception e) {
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<JsonNode> sendTemplate(String jsonTemplate) throws Exception {
|
||||
WebSocket ws = webSocket;
|
||||
if (state != ConnectionState.CONNECTED || ws == null || ws.isOutputClosed()) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Server peer is disconnected"));
|
||||
}
|
||||
String requestId = "server-pool-" + UUID.randomUUID();
|
||||
String requestIdJson = MAPPER.writeValueAsString(requestId);
|
||||
String json = fillRequestId(jsonTemplate, requestIdJson);
|
||||
CompletableFuture<JsonNode> response = new CompletableFuture<>();
|
||||
pending.put(requestId, response);
|
||||
lastActivityAtMs = System.currentTimeMillis();
|
||||
ws.sendText(json, true).whenComplete((ignored, error) -> {
|
||||
if (error != null) {
|
||||
pending.remove(requestId);
|
||||
response.completeExceptionally(error);
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
private void acceptText(long listenerGeneration, String text) {
|
||||
if (generation.get() != listenerGeneration || text == null || text.isBlank()) return;
|
||||
lastActivityAtMs = System.currentTimeMillis();
|
||||
try {
|
||||
JsonNode node = MAPPER.readTree(text);
|
||||
String requestId = node.path("requestId").asText("");
|
||||
CompletableFuture<JsonNode> response = requestId.isBlank() ? null : pending.remove(requestId);
|
||||
if (response != null) {
|
||||
response.complete(node);
|
||||
} else {
|
||||
log.debug("Server pool ignored unmatched frame: server={} requestId={} op={}",
|
||||
serverLogin, requestId, node.path("op").asText(""));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Server pool received invalid JSON: server={} reason={}", serverLogin, compactError(e));
|
||||
}
|
||||
}
|
||||
|
||||
private void healthCheck() {
|
||||
if (state == ConnectionState.DISCONNECTED) {
|
||||
ensureConnectedInBackground(reconnectDelayMillis(reconnectAttempt.get()));
|
||||
return;
|
||||
}
|
||||
if (state != ConnectionState.CONNECTED) return;
|
||||
long now = System.currentTimeMillis();
|
||||
if (pingAwaitedSinceMs > 0L) {
|
||||
if (now - pingAwaitedSinceMs >= TimeUnit.SECONDS.toMillis(pongTimeoutSeconds())) {
|
||||
invalidateConnection("pong_timeout", new TimeoutException("Pong timeout"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (now - lastActivityAtMs < TimeUnit.SECONDS.toMillis(pingIdleSeconds())) return;
|
||||
WebSocket ws = webSocket;
|
||||
if (ws == null || ws.isOutputClosed()) {
|
||||
invalidateConnection("socket_closed", null);
|
||||
return;
|
||||
}
|
||||
long nonce = now;
|
||||
pingAwaitedSinceMs = now;
|
||||
lastPingAtMs = now;
|
||||
ws.sendPing(ByteBuffer.allocate(Long.BYTES).putLong(0, nonce))
|
||||
.whenComplete((ignored, error) -> {
|
||||
if (error != null) invalidateConnection("ping_failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
private void acceptPong(long listenerGeneration) {
|
||||
if (generation.get() != listenerGeneration) return;
|
||||
long now = System.currentTimeMillis();
|
||||
lastPongAtMs = now;
|
||||
lastActivityAtMs = now;
|
||||
pingAwaitedSinceMs = 0L;
|
||||
}
|
||||
|
||||
private void updateWsUrl(String newWsUrl) {
|
||||
if (newWsUrl.equals(wsUrl)) return;
|
||||
wsUrl = newWsUrl;
|
||||
invalidateConnection("peer_url_changed", null);
|
||||
}
|
||||
|
||||
private void ensureConnectedInBackground(long delayMs) {
|
||||
if (closed.get() || state == ConnectionState.CLOSED) return;
|
||||
if (!reconnectScheduled.compareAndSet(false, true)) return;
|
||||
scheduler.schedule(() -> {
|
||||
reconnectScheduled.set(false);
|
||||
if (closed.get() || state == ConnectionState.CLOSED || state == ConnectionState.CONNECTED) return;
|
||||
connectionExecutor.execute(() -> {
|
||||
try {
|
||||
ensureConnectedBlocking();
|
||||
} catch (Exception e) {
|
||||
lastError = compactError(e);
|
||||
// Ошибка connect/ServerHello сама инвалидирует поколение и планирует reconnect.
|
||||
// Здесь второй schedule дал бы двойной рост backoff для одной попытки.
|
||||
}
|
||||
});
|
||||
}, Math.max(0L, delayMs), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void scheduleReconnect() {
|
||||
if (closed.get() || state == ConnectionState.CLOSED) return;
|
||||
int attempt = reconnectAttempt.getAndUpdate(value -> Math.min(value + 1, 30));
|
||||
reconnectCount.incrementAndGet();
|
||||
ensureConnectedInBackground(reconnectDelayMillis(attempt));
|
||||
}
|
||||
|
||||
private long reconnectDelayMillis(int attempt) {
|
||||
long[] seconds = {1L, 2L, 4L, 8L, 15L, 30L, 60L};
|
||||
long base = seconds[Math.min(Math.max(0, attempt), seconds.length - 1)];
|
||||
long halfMs = TimeUnit.SECONDS.toMillis(base) / 2L;
|
||||
long jitterMs = java.util.concurrent.ThreadLocalRandom.current().nextLong(halfMs + 1L);
|
||||
return halfMs + jitterMs;
|
||||
}
|
||||
|
||||
private void invalidateConnection(String reason, Throwable error) {
|
||||
invalidateConnection(generation.get(), reason, error);
|
||||
}
|
||||
|
||||
private void invalidateConnection(long expectedGeneration, String reason, Throwable error) {
|
||||
if (state == ConnectionState.CLOSED
|
||||
|| !generation.compareAndSet(expectedGeneration, expectedGeneration + 1L)) return;
|
||||
lastError = error == null ? reason : reason + ": " + compactError(error);
|
||||
state = ConnectionState.DISCONNECTED;
|
||||
pingAwaitedSinceMs = 0L;
|
||||
WebSocket ws = webSocket;
|
||||
webSocket = null;
|
||||
if (ws != null) {
|
||||
try { ws.abort(); } catch (Exception ignored) {}
|
||||
}
|
||||
Exception failure = new IllegalStateException("Server connection lost: " + serverLogin + " (" + reason + ")");
|
||||
for (Map.Entry<String, CompletableFuture<JsonNode>> entry : pending.entrySet()) {
|
||||
if (pending.remove(entry.getKey(), entry.getValue())) {
|
||||
entry.getValue().completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
private void closeConnection(String reason) {
|
||||
state = ConnectionState.CLOSED;
|
||||
generation.incrementAndGet();
|
||||
WebSocket ws = webSocket;
|
||||
webSocket = null;
|
||||
if (ws != null) {
|
||||
try { ws.sendClose(WebSocket.NORMAL_CLOSURE, reason); } catch (Exception ignored) {}
|
||||
try { ws.abort(); } catch (Exception ignored) {}
|
||||
}
|
||||
IllegalStateException failure = new IllegalStateException("Server connection closed: " + serverLogin);
|
||||
for (CompletableFuture<JsonNode> future : pending.values()) future.completeExceptionally(failure);
|
||||
pending.clear();
|
||||
for (QueuedRequest request : queue) request.result.completeExceptionally(failure);
|
||||
queue.clear();
|
||||
queuedCount.set(0);
|
||||
}
|
||||
|
||||
private PeerMetricsSnapshot snapshot() {
|
||||
int realtime = 0;
|
||||
int normal = 0;
|
||||
int bulk = 0;
|
||||
for (QueuedRequest request : queue) {
|
||||
switch (request.priority) {
|
||||
case REALTIME -> realtime++;
|
||||
case NORMAL -> normal++;
|
||||
case BULK -> bulk++;
|
||||
}
|
||||
}
|
||||
return new PeerMetricsSnapshot(
|
||||
serverLogin, wsUrl, state, connectedAtMs, lastActivityAtMs,
|
||||
lastPingAtMs, lastPongAtMs, reconnectCount.get(), realtime, normal, bulk,
|
||||
successfulRequests.get(), failedRequests.get(), timedOutRequests.get(), lastError);
|
||||
}
|
||||
}
|
||||
|
||||
private record QueuedRequest(
|
||||
Priority priority,
|
||||
long sequence,
|
||||
String jsonTemplate,
|
||||
CompletableFuture<JsonNode> result
|
||||
) implements Comparable<QueuedRequest> {
|
||||
@Override
|
||||
public int compareTo(QueuedRequest other) {
|
||||
int byPriority = Integer.compare(priority.rank, other.priority.rank);
|
||||
return byPriority != 0 ? byPriority : Long.compare(sequence, other.sequence);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Listener implements WebSocket.Listener {
|
||||
private final PeerConnection peer;
|
||||
private final long generation;
|
||||
private final StringBuilder text = new StringBuilder();
|
||||
|
||||
private Listener(PeerConnection peer, long generation) {
|
||||
this.peer = peer;
|
||||
this.generation = generation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket) {
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||
text.append(data);
|
||||
if (last) {
|
||||
peer.acceptText(generation, text.toString());
|
||||
text.setLength(0);
|
||||
}
|
||||
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<?> onPong(WebSocket webSocket, ByteBuffer message) {
|
||||
peer.acceptPong(generation);
|
||||
webSocket.request(1);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||
peer.invalidateConnection(generation, "remote_close_" + statusCode, null);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(WebSocket webSocket, Throwable error) {
|
||||
peer.invalidateConnection(generation, "websocket_error", error);
|
||||
}
|
||||
}
|
||||
|
||||
public static String buildWsUrl(String serverAddressRaw) {
|
||||
if (serverAddressRaw == null) return null;
|
||||
String raw = serverAddressRaw.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;
|
||||
int port = uri.getPort();
|
||||
String authority = host.trim().toLowerCase(Locale.ROOT) + (port > 0 ? ":" + port : "");
|
||||
return "wss://" + authority + "/ws";
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String loginFromAddress(String serverAddress) {
|
||||
String wsUrl = buildWsUrl(serverAddress);
|
||||
if (wsUrl == null) return null;
|
||||
try {
|
||||
return "address:" + URI.create(wsUrl).getAuthority().toLowerCase(Locale.ROOT);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeLogin(String value) {
|
||||
if (value == null) return null;
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private static void ensureOk(String op, JsonNode response) {
|
||||
int status = response == null ? 500 : response.path("status").asInt(500);
|
||||
if (status >= 200 && status < 300) return;
|
||||
String code = response == null ? "EMPTY_RESPONSE" : response.path("code").asText("");
|
||||
if (code.isBlank() && response != null) code = response.path("error").asText("");
|
||||
throw new IllegalStateException(op + " failed: status=" + status + " code=" + code);
|
||||
}
|
||||
|
||||
private static String fillRequestId(String template, String requestIdJson) {
|
||||
int marker = template.indexOf("%s");
|
||||
if (marker < 0) throw new IllegalArgumentException("JSON template has no requestId marker");
|
||||
return template.substring(0, marker) + requestIdJson + template.substring(marker + 2);
|
||||
}
|
||||
|
||||
private static Throwable unwrap(Throwable error) {
|
||||
Throwable current = error;
|
||||
while ((current instanceof java.util.concurrent.CompletionException
|
||||
|| current instanceof java.util.concurrent.ExecutionException)
|
||||
&& current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static Exception unwrap(Exception error) {
|
||||
Throwable unwrapped = unwrap((Throwable) error);
|
||||
return unwrapped instanceof Exception e ? e : new IllegalStateException(unwrapped);
|
||||
}
|
||||
|
||||
private static String compactError(Throwable error) {
|
||||
String text = String.valueOf(error == null ? "unknown" : error.getMessage());
|
||||
return text.length() <= 500 ? text : text.substring(0, 500);
|
||||
}
|
||||
|
||||
private static long requestTimeoutSeconds() {
|
||||
return configLong("server.pool.requestTimeoutSeconds", 12L, 3L, 120L);
|
||||
}
|
||||
|
||||
private static long connectAndHelloTimeoutSeconds() {
|
||||
return configLong("server.pool.connectTimeoutSeconds", 15L, 5L, 120L);
|
||||
}
|
||||
|
||||
private static long pingIdleSeconds() {
|
||||
return configLong("server.pool.pingIdleSeconds", 120L, 15L, 240L);
|
||||
}
|
||||
|
||||
private static long pongTimeoutSeconds() {
|
||||
return configLong("server.pool.pongTimeoutSeconds", 15L, 5L, 120L);
|
||||
}
|
||||
|
||||
private static long configLong(String key, long defaultValue, long min, long max) {
|
||||
String raw = AppConfig.getInstance().getParam(key);
|
||||
if (raw == null || raw.isBlank()) return defaultValue;
|
||||
try {
|
||||
long value = Long.parseLong(raw.trim());
|
||||
return Math.max(min, Math.min(max, value));
|
||||
} catch (Exception ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static ThreadFactory daemonThreadFactory(String prefix) {
|
||||
return new ThreadFactory() {
|
||||
private final AtomicLong sequence = new AtomicLong();
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread thread = new Thread(r, prefix + "-" + sequence.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда.
|
||||
`sourceServerLogin` необязателен для совместимости. Пока межсерверная авторизация отложена, это поле считается доверенным. Пользовательская подпись signed-блока проверяется всегда. Пустой `sourceServerLogin` трактуется как клиентский вызов, непустой - как peer-вызов.
|
||||
|
||||
Успешный ответ содержит существующие `messageKey`, `baseKey` и счётчики realtime-доставки. Повтор уже сохранённой той же ревизии обрабатывается идемпотентно.
|
||||
|
||||
@@ -356,7 +356,11 @@ Pull-синхронизация событий владельца с `synced=fal
|
||||
|
||||
Ответ содержит `messageKey`, `known` и `delivered`. `delivered=true` означает доставку хотя бы одному серверу получателя.
|
||||
|
||||
Периодический процесс использует одно WS-соединение с peer: сначала синхронизирует настройки, затем вызывает `DmSyncBatch` до завершения страниц и ACK. Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает `dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет.
|
||||
Периодический процесс использует один логический последовательный сеанс поверх
|
||||
постоянного WSS-пула: сначала синхронизирует настройки, затем вызывает
|
||||
`DmSyncBatch` до завершения страниц и ACK. Существующий
|
||||
`MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги и возвращает
|
||||
`dmUpdated`; отдельной операции `MarkAllDmUnsynced` нет.
|
||||
|
||||
Основные ошибки межсерверных операций:
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Межсерверное соединение и `ServerHello`
|
||||
|
||||
Документ описывает транспортный слой постоянных WSS-соединений между серверами SHiNE.
|
||||
Он не меняет форматы DM, блоков, настроек, ACK или расписания повторных попыток.
|
||||
|
||||
## 1. `ServerHello`
|
||||
|
||||
После установления исходящего WSS-соединения сервер первым запросом отправляет:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "ServerHello",
|
||||
"requestId": "server-hello-001",
|
||||
"payload": {
|
||||
"serverLogin": "shineupme",
|
||||
"protocolVersion": 1,
|
||||
"capabilities": [
|
||||
"dm-sync",
|
||||
"settings-sync",
|
||||
"block-sync",
|
||||
"connection-pool"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "ServerHello",
|
||||
"requestId": "server-hello-001",
|
||||
"status": 200,
|
||||
"payload": {
|
||||
"accepted": true,
|
||||
"serverLogin": "server2",
|
||||
"protocolVersion": 1,
|
||||
"capabilities": [
|
||||
"dm-sync",
|
||||
"settings-sync",
|
||||
"block-sync",
|
||||
"connection-pool"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
На текущем этапе `serverLogin` принимается на доверии. Подпись, challenge и
|
||||
проверка корневого ключа сервера намеренно отложены. Поэтому `ServerHello`
|
||||
фиксирует тип соединения и возможности peer, но пока не является
|
||||
криптографической аутентификацией.
|
||||
|
||||
## 2. Пул соединений
|
||||
|
||||
- физическое соединение создаётся одно на `serverLogin`;
|
||||
- логические операции DM, settings и blockchain используют один WSS;
|
||||
- завершение `RemoteSyncSession` не закрывает физический сокет;
|
||||
- известные peer берутся из `sync_servers` и `user_access_servers_current`;
|
||||
- список перечитывается каждые 30 секунд;
|
||||
- при изменении URL соединение пересоздаётся;
|
||||
- новые запросы не повторяются транспортом автоматически: действующие
|
||||
domain-воркеры сохраняют прежние правила retry и идемпотентности.
|
||||
|
||||
## 3. Приоритеты
|
||||
|
||||
| Приоритет | Операции |
|
||||
| --- | --- |
|
||||
| `REALTIME` | доставка DM, deletes, `GetDmDeliveryStatus` |
|
||||
| `NORMAL` | настройки, `DmSyncBatch` и access-data sync |
|
||||
| `BULK` | blockchain heads, blocks, `AddBlock` backfill |
|
||||
|
||||
На одном peer одновременно исполняется один запрос. Приоритет применяется к
|
||||
ожидающей очереди и не прерывает уже начатую операцию.
|
||||
|
||||
## 4. Ping, reconnect и таймауты
|
||||
|
||||
Значения по умолчанию:
|
||||
|
||||
| Параметр | Значение |
|
||||
| --- | ---: |
|
||||
| `server.pool.pingIdleSeconds` | `120` |
|
||||
| `server.pool.pongTimeoutSeconds` | `15` |
|
||||
| `server.pool.requestTimeoutSeconds` | `12` |
|
||||
| `server.pool.connectTimeoutSeconds` | `15` |
|
||||
| `server.pool.callerTimeoutSeconds` | `35` |
|
||||
| `server.pool.maxQueuePerPeer` | `2000` |
|
||||
|
||||
Ping отправляется WebSocket control-frame только после периода отсутствия
|
||||
полезного трафика. При потере соединения применяется reconnect с jitter и
|
||||
ступенями до 60 секунд.
|
||||
|
||||
## 5. Диагностика
|
||||
|
||||
Внутренний snapshot пула хранит для каждого peer:
|
||||
|
||||
- состояние соединения;
|
||||
- URL;
|
||||
- времена connect/activity/ping/pong;
|
||||
- число reconnect;
|
||||
- размеры очередей по приоритетам;
|
||||
- успешные, ошибочные и просроченные запросы;
|
||||
- последнюю ошибку.
|
||||
|
||||
Агрегированное состояние периодически записывается в серверный лог. Отдельная
|
||||
публичная операция метрик на этом этапе не добавляется.
|
||||
@@ -103,6 +103,8 @@ DM считается доставленным, когда signed-входящу
|
||||
|
||||
При обрыве соединения неподтверждённый элемент остаётся `synced=false` и безопасно приходит повторно. Элемент, полученный от peer, локально сразу считается синхронизированным, чтобы не образовалась петля.
|
||||
|
||||
`sourceServerLogin` является единственным признаком межсерверного вызова для этих операций: если поле пустое, запрос считается клиентским и его запись не должна сразу переводиться в `synced=true`.
|
||||
|
||||
Синхронизация настроек и DM проходит последовательно через один WS-сеанс: сначала настройки, затем все страницы DM. Если второй сервер был выключен, после включения он сам догружает пропущенные элементы.
|
||||
|
||||
Существующий `MarkAllUserSettingsUnsynced` также сбрасывает DM-флаги. После сброса история повторно передаётся как синхронизация, но старые сообщения получателю заново не отправляются: delivery-состояние создаётся с учётом их возраста и не открывает завершённую часовую очередь.
|
||||
|
||||
@@ -489,7 +489,7 @@ Request:
|
||||
}
|
||||
```
|
||||
|
||||
`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки.
|
||||
`sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Для `ReceiveOutcomingMessage` и `SendMessagePair` пустой `sourceServerLogin` означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки.
|
||||
|
||||
### 10.3. `DeleteMessage`
|
||||
|
||||
@@ -616,7 +616,10 @@ UI-следствие для клиента:
|
||||
- не отправляет realtime/push-уведомления клиентам;
|
||||
- не запускает повторный fan-out, чтобы не создавать циклы.
|
||||
|
||||
Синхронизация настроек и DM выполняется одним периодическим процессом и через один последовательный WS-сеанс с peer. Выборка DM использует частичный индекс по `synced=false`.
|
||||
Синхронизация настроек и DM выполняется одним периодическим процессом и через
|
||||
один логический последовательный сеанс поверх постоянного WSS-пула. Закрытие
|
||||
этого логического сеанса не закрывает физическое соединение с peer. Выборка DM
|
||||
использует частичный индекс по `synced=false`.
|
||||
|
||||
В текущей реализации межсерверная авторизация DM ещё не включена. Принимающий сервер проверяет, что сам является access-сервером `ownerLogin`, и всегда проверяет пользовательские подписи signed-блоков.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user