Добавить догоняющую синхронизацию DM между access-серверами

This commit is contained in:
AidarKC
2026-07-29 13:59:40 +04:00
parent ca8b6a33ba
commit 143adcbbe4
21 changed files with 1025 additions and 13 deletions
@@ -19,8 +19,10 @@ public final class DatabaseInitializer {
public static final String DB_SCHEMA_VERSION_TABLE = "db_schema_version";
public static final int SCHEMA_VERSION_1 = 1;
public static final int SCHEMA_VERSION_2 = 2;
public static final int SCHEMA_VERSION_3 = 3;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
private DatabaseInitializer() {}
@@ -82,6 +84,10 @@ public final class DatabaseInitializer {
int currentVersion = readCurrentSchemaVersion(conn);
if (currentVersion < SCHEMA_VERSION_2) {
runSqlScript(conn, POSTGRES_MIGRATION_V2_RESOURCE);
currentVersion = SCHEMA_VERSION_2;
}
if (currentVersion < SCHEMA_VERSION_3) {
runSqlScript(conn, POSTGRES_MIGRATION_V3_RESOURCE);
}
}
}
@@ -0,0 +1,173 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.DmSyncPeerStateEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class DmSyncPeerStateDAO {
private static volatile DmSyncPeerStateDAO instance;
private final DbController db = DbController.getInstance();
private DmSyncPeerStateDAO() {}
public static DmSyncPeerStateDAO getInstance() {
if (instance == null) {
synchronized (DmSyncPeerStateDAO.class) {
if (instance == null) instance = new DmSyncPeerStateDAO();
}
}
return instance;
}
public DmSyncPeerStateEntry getOrCreate(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
DmSyncPeerStateEntry existing = get(ownerLogin, remoteServerLogin);
if (existing != null) {
if (remoteServerUrl != null && !remoteServerUrl.isBlank()
&& !remoteServerUrl.equals(existing.getRemoteServerUrl())) {
touchRemoteUrl(ownerLogin, remoteServerLogin, remoteServerUrl);
existing.setRemoteServerUrl(remoteServerUrl);
}
return existing;
}
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, NULL, ?)
ON CONFLICT DO NOTHING
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setLong(4, nowMs);
ps.executeUpdate();
}
existing = get(ownerLogin, remoteServerLogin);
if (existing == null) {
throw new SQLException("Failed to create dm_sync_peer_state row");
}
return existing;
}
public void updateSuccess(
String ownerLogin,
String remoteServerLogin,
String remoteServerUrl,
long cursorStoredAtMs,
String cursorMessageKey,
boolean bootstrapCompleted
) throws SQLException {
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
cursor_stored_at_ms = EXCLUDED.cursor_stored_at_ms,
cursor_message_key = EXCLUDED.cursor_message_key,
bootstrap_completed = EXCLUDED.bootstrap_completed,
last_sync_at_ms = EXCLUDED.last_sync_at_ms,
last_error = NULL,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setLong(4, Math.max(0L, cursorStoredAtMs));
ps.setString(5, cursorMessageKey == null ? "" : cursorMessageKey);
ps.setBoolean(6, bootstrapCompleted);
ps.setLong(7, nowMs);
ps.setLong(8, nowMs);
ps.executeUpdate();
}
}
public void updateError(String ownerLogin, String remoteServerLogin, String remoteServerUrl, String error) throws SQLException {
long nowMs = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO dm_sync_peer_state (
owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
) VALUES (?, ?, ?, 0, '', FALSE, NULL, ?, ?)
ON CONFLICT (owner_login, remote_server_login) DO UPDATE SET
remote_server_url = EXCLUDED.remote_server_url,
last_error = EXCLUDED.last_error,
updated_at_ms = EXCLUDED.updated_at_ms
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
ps.setString(3, remoteServerUrl == null ? "" : remoteServerUrl);
ps.setString(4, truncate(error));
ps.setLong(5, nowMs);
ps.executeUpdate();
}
}
private DmSyncPeerStateEntry get(String ownerLogin, String remoteServerLogin) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT owner_login, remote_server_login, remote_server_url,
cursor_stored_at_ms, cursor_message_key, bootstrap_completed,
last_sync_at_ms, last_error, updated_at_ms
FROM dm_sync_peer_state
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(remote_server_login) = LOWER(?)
LIMIT 1
""")) {
ps.setString(1, ownerLogin);
ps.setString(2, remoteServerLogin);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
DmSyncPeerStateEntry e = new DmSyncPeerStateEntry();
e.setOwnerLogin(rs.getString("owner_login"));
e.setRemoteServerLogin(rs.getString("remote_server_login"));
e.setRemoteServerUrl(rs.getString("remote_server_url"));
e.setCursorStoredAtMs(rs.getLong("cursor_stored_at_ms"));
e.setCursorMessageKey(rs.getString("cursor_message_key"));
e.setBootstrapCompleted(rs.getBoolean("bootstrap_completed"));
long lastSyncAt = rs.getLong("last_sync_at_ms");
e.setLastSyncAtMs(rs.wasNull() ? null : lastSyncAt);
e.setLastError(rs.getString("last_error"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
return e;
}
}
}
private void touchRemoteUrl(String ownerLogin, String remoteServerLogin, String remoteServerUrl) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE dm_sync_peer_state
SET remote_server_url = ?, updated_at_ms = ?
WHERE LOWER(owner_login) = LOWER(?)
AND LOWER(remote_server_login) = LOWER(?)
""")) {
ps.setString(1, remoteServerUrl);
ps.setLong(2, System.currentTimeMillis());
ps.setString(3, ownerLogin);
ps.setString(4, remoteServerLogin);
ps.executeUpdate();
}
}
private static String truncate(String value) {
if (value == null) return null;
String clean = value.trim();
if (clean.length() <= 500) return clean;
return clean.substring(0, 500);
}
}
@@ -416,6 +416,72 @@ public final class SignedMessagesDAO {
}
}
public SyncBatch listSyncBatch(
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes
) throws Exception {
int safeLimit = Math.max(1, Math.min(limit, 500));
int sqlLimit = safeLimit + 1;
String safeAfterMessageKey = afterMessageKey == null ? "" : afterMessageKey;
try (Connection c = db.getConnection()) {
String sql = """
SELECT
message_key, base_key, target_login, from_login, to_login,
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
raw_block, created_at_ms, source_api, origin_session_id,
receipt_ref_base_key, receipt_ref_type, read_at_ms
FROM %s
WHERE (
LOWER(target_login) = LOWER(?)
OR (
message_type IN (5, 6, 7, 8)
AND (LOWER(from_login) = LOWER(?) OR LOWER(to_login) = LOWER(?))
)
)
AND (
created_at_ms > ?
OR (created_at_ms = ? AND (? = '' OR message_key > ?))
)
ORDER BY created_at_ms ASC, message_key ASC
LIMIT ?
""".formatted(messagesTable());
List<SignedMessageEntry> out = new ArrayList<>();
boolean hasMore = false;
int usedBytes = 0;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerLogin);
ps.setString(2, ownerLogin);
ps.setString(3, ownerLogin);
ps.setLong(4, Math.max(0L, afterStoredAtMs));
ps.setLong(5, Math.max(0L, afterStoredAtMs));
ps.setString(6, safeAfterMessageKey);
ps.setString(7, safeAfterMessageKey);
ps.setInt(8, sqlLimit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
SignedMessageEntry row = mapRow(rs);
byte[] raw = row.getRawBlock();
int rowBytes = raw == null ? 0 : raw.length;
if (out.size() >= safeLimit) {
hasMore = true;
break;
}
if (!out.isEmpty() && maxBytes > 0 && usedBytes + rowBytes > maxBytes) {
hasMore = true;
break;
}
out.add(row);
usedBytes += rowBytes;
}
}
}
return new SyncBatch(out, hasMore, usedBytes);
}
}
private void upsertMessage(Connection c, SignedMessageEntry e) throws SQLException {
String sql = """
INSERT INTO %s (
@@ -748,6 +814,8 @@ public final class SignedMessagesDAO {
}
}
public record SyncBatch(List<SignedMessageEntry> items, boolean hasMore, int rawBytes) {}
@FunctionalInterface
private interface SqlWork<T> {
T run() throws Exception;
@@ -55,6 +55,26 @@ public final class UserAccessServersCurrentDAO {
return result;
}
public List<String> listUserLoginsByServerLogin(String serverLogin) throws SQLException {
String sql = """
SELECT user_login
FROM user_access_servers_current
WHERE LOWER(server_login) = LOWER(?)
ORDER BY user_login
""";
List<String> result = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, serverLogin);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(rs.getString("user_login"));
}
}
}
return result;
}
private UserAccessServerRouteEntry mapRow(ResultSet rs) throws SQLException {
UserAccessServerRouteEntry entry = new UserAccessServerRouteEntry();
entry.setUserLogin(rs.getString("user_login"));
@@ -0,0 +1,32 @@
package shine.db.entities;
public class DmSyncPeerStateEntry {
private String ownerLogin;
private String remoteServerLogin;
private String remoteServerUrl;
private long cursorStoredAtMs;
private String cursorMessageKey;
private boolean bootstrapCompleted;
private Long lastSyncAtMs;
private String lastError;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getRemoteServerLogin() { return remoteServerLogin; }
public void setRemoteServerLogin(String remoteServerLogin) { this.remoteServerLogin = remoteServerLogin; }
public String getRemoteServerUrl() { return remoteServerUrl; }
public void setRemoteServerUrl(String remoteServerUrl) { this.remoteServerUrl = remoteServerUrl; }
public long getCursorStoredAtMs() { return cursorStoredAtMs; }
public void setCursorStoredAtMs(long cursorStoredAtMs) { this.cursorStoredAtMs = cursorStoredAtMs; }
public String getCursorMessageKey() { return cursorMessageKey; }
public void setCursorMessageKey(String cursorMessageKey) { this.cursorMessageKey = cursorMessageKey; }
public boolean isBootstrapCompleted() { return bootstrapCompleted; }
public void setBootstrapCompleted(boolean bootstrapCompleted) { this.bootstrapCompleted = bootstrapCompleted; }
public Long getLastSyncAtMs() { return lastSyncAtMs; }
public void setLastSyncAtMs(Long lastSyncAtMs) { this.lastSyncAtMs = lastSyncAtMs; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}
@@ -0,0 +1,47 @@
BEGIN;
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
ON signed_messages(target_login, created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
ON signed_messages(from_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
ON signed_messages(to_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
cursor_message_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
ON dm_sync_peer_state(owner_login);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
COMMIT;
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 2, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 3, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
@@ -652,6 +652,28 @@ CREATE INDEX IF NOT EXISTS idx_signed_messages_target
CREATE INDEX IF NOT EXISTS idx_signed_messages_base
ON signed_messages(base_key, message_type);
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor
ON signed_messages(target_login, created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_sync_cursor_lower
ON signed_messages(LOWER(target_login), created_at_ms, message_key);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor
ON signed_messages(from_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor
ON signed_messages(to_login, created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_from_sync_cursor_lower
ON signed_messages(LOWER(from_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE INDEX IF NOT EXISTS idx_signed_messages_delete_to_sync_cursor_lower
ON signed_messages(LOWER(to_login), created_at_ms, message_key)
WHERE message_type IN (5, 6, 7, 8);
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_receipt_incoming
ON signed_messages(target_login, receipt_ref_base_key)
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
@@ -672,6 +694,22 @@ CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
ON signed_message_session_delivery(session_id, delivered);
CREATE TABLE IF NOT EXISTS dm_sync_peer_state (
owner_login TEXT NOT NULL,
remote_server_login TEXT NOT NULL,
remote_server_url TEXT NOT NULL,
cursor_stored_at_ms BIGINT NOT NULL DEFAULT 0,
cursor_message_key TEXT NOT NULL DEFAULT '',
bootstrap_completed BOOLEAN NOT NULL DEFAULT FALSE,
last_sync_at_ms BIGINT,
last_error TEXT,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (owner_login, remote_server_login)
);
CREATE INDEX IF NOT EXISTS idx_dm_sync_peer_state_owner
ON dm_sync_peer_state(owner_login);
CREATE TABLE IF NOT EXISTS message_views_state (
viewer_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
@@ -88,6 +88,7 @@ import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_DmSyncBatch_Handler;
import server.logic.ws_protocol.JSON.messages.Net_GetDirectMessages_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
@@ -100,6 +101,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_R
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
@@ -198,6 +200,7 @@ public final class JsonHandlerRegistry {
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
Map.entry("DmSyncBatch", new Net_DmSyncBatch_Handler()),
Map.entry("GetDirectMessages", new Net_GetDirectMessages_Handler()),
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
@@ -278,6 +281,7 @@ public final class JsonHandlerRegistry {
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
Map.entry("DmSyncBatch", Net_DmSyncBatch_Request.class),
Map.entry("GetDirectMessages", Net_GetDirectMessages_Request.class),
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
@@ -0,0 +1,50 @@
package server.logic.ws_protocol.JSON.messages;
import shine.db.dao.SignedMessagesDAO;
import shine.db.entities.SignedMessageEntry;
public final class DmSyncApplySupport {
private DmSyncApplySupport() {}
public static ApplyResult applySyncedBlob(String ownerLogin, String blobB64) throws Exception {
if (ownerLogin == null || ownerLogin.isBlank()) {
throw new IllegalArgumentException("EMPTY_OWNER_LOGIN");
}
if (blobB64 == null || blobB64.isBlank()) {
throw new IllegalArgumentException("EMPTY_BLOB");
}
SignedMessageBlock block = SignedMessagesCore.parseFromB64(blobB64);
SignedMessagesCore.verifyUsersAndSignature(block);
SignedMessageEntry entry = SignedMessagesCore.toEntry(block, "DmSyncBatch", null);
String owner = ownerLogin.trim();
boolean ownerMatchesTarget = entry.getTargetLogin().equalsIgnoreCase(owner);
boolean ownerMatchesDeletePair = block.isDeleteType()
&& (entry.getFromLogin().equalsIgnoreCase(owner) || entry.getToLogin().equalsIgnoreCase(owner));
if (!ownerMatchesTarget && !ownerMatchesDeletePair) {
throw new IllegalArgumentException("TARGET_LOGIN_MISMATCH");
}
SignedMessagesDAO.ApplyStatus status;
if (block.isContentType()) {
status = SignedMessagesDAO.getInstance().upsertIncomingCopy(entry);
} else if (block.isReadReceiptType()) {
status = SignedMessagesDAO.getInstance().insertIfAbsent(entry);
} else if (block.isMessageDeleteType()) {
status = SignedMessagesDAO.getInstance().applyDeleteMessage(entry);
} else if (block.isConversationDeleteType()) {
status = SignedMessagesDAO.getInstance().applyDeleteConversation(entry);
} else {
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
}
return new ApplyResult(entry.getMessageKey(), entry.getBaseKey(), entry.getMessageType(), status);
}
public record ApplyResult(
String messageKey,
String baseKey,
int messageType,
SignedMessagesDAO.ApplyStatus status
) {}
}
@@ -0,0 +1,114 @@
package server.logic.ws_protocol.JSON.messages;
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.messages.entyties.Net_DmSyncBatch_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_DmSyncBatch_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.SignedMessageEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class Net_DmSyncBatch_Handler implements JsonMessageHandler {
private static final int DEFAULT_LIMIT = 500;
private static final int MAX_LIMIT = 500;
private static final int DEFAULT_MAX_BYTES = 3_000_000;
private static final int MAX_BYTES_CAP = 5_000_000;
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_DmSyncBatch_Request req = (Net_DmSyncBatch_Request) baseRequest;
String ownerLogin = normalizeOriginal(req.getOwnerLogin());
if (ownerLogin == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_OWNER_LOGIN", "ownerLogin обязателен");
}
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
return NetExceptionResponseFactory.error(req, 500, "LOCAL_SERVER_NOT_CONFIGURED", "server.SHiNE.login не настроен");
}
if (!isLocalAccessServer(ownerLogin, ownServerLogin)) {
return NetExceptionResponseFactory.error(req, 403, "LOCAL_SERVER_NOT_ACCESS_SERVER", "Локальный сервер не является access-сервером пользователя");
}
int limit = clamp(req.getLimit() == null ? DEFAULT_LIMIT : req.getLimit(), 1, MAX_LIMIT);
int maxBytes = clamp(req.getMaxBytes() == null ? DEFAULT_MAX_BYTES : req.getMaxBytes(), 64_000, MAX_BYTES_CAP);
long afterStoredAtMs = Math.max(0L, req.getAfterStoredAtMs() == null ? 0L : req.getAfterStoredAtMs());
String afterMessageKey = req.getAfterMessageKey() == null ? "" : req.getAfterMessageKey().trim();
SignedMessagesDAO.SyncBatch batch = SignedMessagesDAO.getInstance().listSyncBatch(
ownerLogin,
afterStoredAtMs,
afterMessageKey,
limit,
maxBytes
);
Net_DmSyncBatch_Response resp = new Net_DmSyncBatch_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setOwnerLogin(ownerLogin);
resp.setLimit(limit);
resp.setRawBytes(batch.rawBytes());
resp.setHasMore(batch.hasMore());
resp.setNextStoredAtMs(afterStoredAtMs);
resp.setNextMessageKey(afterMessageKey);
List<Net_DmSyncBatch_Response.Item> items = new ArrayList<>();
Base64.Encoder encoder = Base64.getEncoder();
for (SignedMessageEntry entry : batch.items()) {
Net_DmSyncBatch_Response.Item item = new Net_DmSyncBatch_Response.Item();
item.setMessageKey(entry.getMessageKey());
item.setBaseKey(entry.getBaseKey());
item.setTargetLogin(entry.getTargetLogin());
item.setFromLogin(entry.getFromLogin());
item.setToLogin(entry.getToLogin());
item.setMessageType(entry.getMessageType());
item.setTimeMs(entry.getTimeMs());
item.setStoredAtMs(entry.getCreatedAtMs());
item.setBlobB64(encoder.encodeToString(entry.getRawBlock()));
items.add(item);
resp.setNextStoredAtMs(entry.getCreatedAtMs());
resp.setNextMessageKey(entry.getMessageKey());
}
resp.setItems(items);
return resp;
}
private boolean isLocalAccessServer(String ownerLogin, String ownServerLogin) throws Exception {
for (UserAccessServerRouteEntry route : UserAccessServersCurrentDAO.getInstance().listByUserLogin(ownerLogin)) {
if (route == null || route.getServerLogin() == null) continue;
if (ownServerLogin.equals(normalize(route.getServerLogin()))) {
return true;
}
}
return false;
}
private static int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase();
return s.isEmpty() ? null : s;
}
private static String normalizeOriginal(String value) {
if (value == null) return null;
String s = value.trim();
return s.isEmpty() ? null : s;
}
}
@@ -0,0 +1,22 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_DmSyncBatch_Request extends Net_Request {
private String ownerLogin;
private Long afterStoredAtMs;
private String afterMessageKey;
private Integer limit;
private Integer maxBytes;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public Long getAfterStoredAtMs() { return afterStoredAtMs; }
public void setAfterStoredAtMs(Long afterStoredAtMs) { this.afterStoredAtMs = afterStoredAtMs; }
public String getAfterMessageKey() { return afterMessageKey; }
public void setAfterMessageKey(String afterMessageKey) { this.afterMessageKey = afterMessageKey; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Integer getMaxBytes() { return maxBytes; }
public void setMaxBytes(Integer maxBytes) { this.maxBytes = maxBytes; }
}
@@ -0,0 +1,62 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_DmSyncBatch_Response extends Net_Response {
private String ownerLogin;
private int limit;
private int rawBytes;
private boolean hasMore;
private long nextStoredAtMs;
private String nextMessageKey;
private List<Item> items = new ArrayList<>();
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public int getLimit() { return limit; }
public void setLimit(int limit) { this.limit = limit; }
public int getRawBytes() { return rawBytes; }
public void setRawBytes(int rawBytes) { this.rawBytes = rawBytes; }
public boolean isHasMore() { return hasMore; }
public void setHasMore(boolean hasMore) { this.hasMore = hasMore; }
public long getNextStoredAtMs() { return nextStoredAtMs; }
public void setNextStoredAtMs(long nextStoredAtMs) { this.nextStoredAtMs = nextStoredAtMs; }
public String getNextMessageKey() { return nextMessageKey; }
public void setNextMessageKey(String nextMessageKey) { this.nextMessageKey = nextMessageKey; }
public List<Item> getItems() { return items; }
public void setItems(List<Item> items) { this.items = items; }
public static class Item {
private String messageKey;
private String baseKey;
private String targetLogin;
private String fromLogin;
private String toLogin;
private int messageType;
private long timeMs;
private long storedAtMs;
private String blobB64;
public String getMessageKey() { return messageKey; }
public void setMessageKey(String messageKey) { this.messageKey = messageKey; }
public String getBaseKey() { return baseKey; }
public void setBaseKey(String baseKey) { this.baseKey = baseKey; }
public String getTargetLogin() { return targetLogin; }
public void setTargetLogin(String targetLogin) { this.targetLogin = targetLogin; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public int getMessageType() { return messageType; }
public void setMessageType(int messageType) { this.messageType = messageType; }
public long getTimeMs() { return timeMs; }
public void setTimeMs(long timeMs) { this.timeMs = timeMs; }
public long getStoredAtMs() { return storedAtMs; }
public void setStoredAtMs(long storedAtMs) { this.storedAtMs = storedAtMs; }
public String getBlobB64() { return blobB64; }
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
}
}
@@ -8,6 +8,8 @@ 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.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
@@ -53,6 +55,51 @@ public final class RemoteDmSyncClient {
ensureOk("ReceiveIncomingMessage", response);
}
public RemoteDmBatch dmSyncBatch(
String serverAddressRaw,
String ownerLogin,
long afterStoredAtMs,
String afterMessageKey,
int limit,
int maxBytes
) throws Exception {
String ownerLoginJson = MAPPER.writeValueAsString(ownerLogin);
String afterMessageKeyJson = MAPPER.writeValueAsString(afterMessageKey == null ? "" : afterMessageKey);
JsonNode response = send(serverAddressRaw, """
{
"op":"DmSyncBatch",
"requestId":%s,
"payload":{
"ownerLogin":%s,
"afterStoredAtMs":%d,
"afterMessageKey":%s,
"limit":%d,
"maxBytes":%d
}
}
""".formatted("%s", ownerLoginJson, Math.max(0L, afterStoredAtMs), afterMessageKeyJson, limit, maxBytes));
ensureOk("DmSyncBatch", response);
JsonNode payload = response.path("payload");
List<RemoteDmItem> items = new ArrayList<>();
JsonNode arr = payload.path("items");
if (arr.isArray()) {
for (JsonNode item : arr) {
items.add(new RemoteDmItem(
item.path("messageKey").asText(""),
item.path("storedAtMs").asLong(0L),
item.path("blobB64").asText("")
));
}
}
return new RemoteDmBatch(
payload.path("nextStoredAtMs").asLong(afterStoredAtMs),
payload.path("nextMessageKey").asText(afterMessageKey == null ? "" : afterMessageKey),
payload.path("hasMore").asBoolean(false),
items
);
}
public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception {
String blobJson = MAPPER.writeValueAsString(blobB64);
JsonNode response = send(serverAddressRaw, """
@@ -135,6 +182,19 @@ public final class RemoteDmSyncClient {
}
}
public record RemoteDmBatch(
long nextStoredAtMs,
String nextMessageKey,
boolean hasMore,
List<RemoteDmItem> items
) {}
public record RemoteDmItem(
String messageKey,
long storedAtMs,
String blobB64
) {}
private static final class SyncWsListener implements WebSocket.Listener {
private final CompletableFuture<String> responseFuture;
private final CountDownLatch openLatch;
@@ -0,0 +1,185 @@
package server.sync;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.messages.DmSyncApplySupport;
import shine.db.dao.DmSyncPeerStateDAO;
import shine.db.dao.UserAccessServersCurrentDAO;
import shine.db.entities.DmSyncPeerStateEntry;
import shine.db.entities.UserAccessServerRouteEntry;
import utils.config.AppConfig;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Периодическая догоняющая синхронизация личной переписки между access-серверами пользователя.
*/
public final class PeriodicDmSyncService {
private static final Logger log = LoggerFactory.getLogger(PeriodicDmSyncService.class);
private static final String SERVER_LOGIN_CONFIG = "server.SHiNE.login";
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient();
private static final UserAccessServersCurrentDAO ACCESS_DAO = UserAccessServersCurrentDAO.getInstance();
private static final DmSyncPeerStateDAO STATE_DAO = DmSyncPeerStateDAO.getInstance();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "periodic-dm-sync");
t.setDaemon(true);
return t;
}
});
private PeriodicDmSyncService() {}
public static void startOrLog() {
if (!isEnabled()) {
log.info("Periodic DM sync disabled by dm.sync.enabled=false");
return;
}
if (!STARTED.compareAndSet(false, true)) {
return;
}
long initialDelaySec = configLong("dm.sync.initialDelaySeconds", 60L, 0L, 3600L);
long periodHours = configLong("dm.sync.periodHours", 6L, 1L, 168L);
EXECUTOR.scheduleWithFixedDelay(
PeriodicDmSyncService::runCycleSafe,
initialDelaySec,
TimeUnit.HOURS.toSeconds(periodHours),
TimeUnit.SECONDS
);
log.info("Periodic DM sync scheduled: first run in {} seconds, then every {} hours", initialDelaySec, periodHours);
}
private static void runCycleSafe() {
try {
runCycle();
} catch (Exception e) {
log.error("Periodic DM sync failed unexpectedly", e);
}
}
private static void runCycle() throws Exception {
String ownServerLogin = normalize(AppConfig.getInstance().getParam(SERVER_LOGIN_CONFIG));
if (ownServerLogin == null) {
log.warn("Periodic DM sync skipped: {} is empty", SERVER_LOGIN_CONFIG);
return;
}
List<String> ownersRaw = ACCESS_DAO.listUserLoginsByServerLogin(ownServerLogin);
Set<String> owners = new LinkedHashSet<>(ownersRaw);
if (owners.isEmpty()) {
log.info("Periodic DM sync skipped: no local access-server users for {}", ownServerLogin);
return;
}
int syncedPeers = 0;
int appliedEvents = 0;
for (String ownerLogin : owners) {
List<UserAccessServerRouteEntry> routes = ACCESS_DAO.listByUserLogin(ownerLogin);
for (UserAccessServerRouteEntry route : routes) {
if (route == null) continue;
String remoteLogin = normalize(route.getServerLogin());
String remoteUrl = route.getServerUrl();
if (remoteLogin == null || remoteUrl == null || remoteUrl.isBlank()) continue;
if (remoteLogin.equals(ownServerLogin)) continue;
try {
appliedEvents += syncOwnerFromRemote(ownerLogin, route);
syncedPeers++;
} catch (Exception e) {
STATE_DAO.updateError(ownerLogin, route.getServerLogin(), remoteUrl, String.valueOf(e));
log.warn("Periodic DM sync peer failed: owner={} remoteServer={} reason={}",
ownerLogin, route.getServerLogin(), String.valueOf(e));
}
}
}
log.info("Periodic DM sync cycle finished: owners={} syncedPeers={} appliedEvents={}",
owners.size(), syncedPeers, appliedEvents);
}
private static int syncOwnerFromRemote(String ownerLogin, UserAccessServerRouteEntry route) throws Exception {
int limit = (int) configLong("dm.sync.batchLimit", 500L, 1L, 500L);
int maxBytes = (int) configLong("dm.sync.batchMaxBytes", 3_000_000L, 64_000L, 5_000_000L);
int maxPages = (int) configLong("dm.sync.maxPagesPerPeer", 50L, 1L, 500L);
DmSyncPeerStateEntry state = STATE_DAO.getOrCreate(ownerLogin, route.getServerLogin(), route.getServerUrl());
long cursorStoredAtMs = state.getCursorStoredAtMs();
String cursorMessageKey = state.getCursorMessageKey() == null ? "" : state.getCursorMessageKey();
int applied = 0;
boolean bootstrapCompleted = false;
for (int page = 0; page < maxPages; page++) {
RemoteDmSyncClient.RemoteDmBatch batch = REMOTE.dmSyncBatch(
route.getServerUrl(),
ownerLogin,
cursorStoredAtMs,
cursorMessageKey,
limit,
maxBytes
);
for (RemoteDmSyncClient.RemoteDmItem item : batch.items()) {
if (item == null || item.blobB64() == null || item.blobB64().isBlank()) continue;
DmSyncApplySupport.ApplyResult result = DmSyncApplySupport.applySyncedBlob(ownerLogin, item.blobB64());
if (result.status().applied()) {
applied++;
}
}
cursorStoredAtMs = Math.max(cursorStoredAtMs, batch.nextStoredAtMs());
cursorMessageKey = batch.nextMessageKey() == null ? "" : batch.nextMessageKey();
bootstrapCompleted = !batch.hasMore();
STATE_DAO.updateSuccess(
ownerLogin,
route.getServerLogin(),
route.getServerUrl(),
cursorStoredAtMs,
cursorMessageKey,
bootstrapCompleted
);
if (!batch.hasMore() || batch.items().isEmpty()) {
break;
}
}
if (!bootstrapCompleted) {
log.info("Periodic DM sync peer paused by page limit: owner={} remoteServer={} maxPages={}",
ownerLogin, route.getServerLogin(), maxPages);
}
return applied;
}
private static boolean isEnabled() {
String raw = AppConfig.getInstance().getParam("dm.sync.enabled");
return raw == null || raw.isBlank() || Boolean.parseBoolean(raw.trim());
}
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 parsed = Long.parseLong(raw.trim());
return Math.max(min, Math.min(max, parsed));
} catch (Exception ignored) {
return defaultValue;
}
}
private static String normalize(String value) {
if (value == null) return null;
String s = value.trim().toLowerCase(Locale.ROOT);
return s.isEmpty() ? null : s;
}
}
@@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory;
import server.debug.DebugApiConfigurator;
import server.sync.BlockchainResyncRecoveryOnStartup;
import server.sync.PeriodicBlockchainSyncService;
import server.sync.PeriodicDmSyncService;
import server.sync.SolanaUsersSyncStartupService;
import server.sync.SyncServersBootstrapService;
import utils.config.AppConfig;
@@ -102,6 +103,7 @@ public final class WsServer {
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
PeriodicDmSyncService.startOrLog();
server.join();
}
}
@@ -33,6 +33,14 @@ sync.importUserProfileFromPartner.enabled=false
# Если какое-то значение не задано, сервер вернёт пустую строку.
# ------------------------------------------------------------
server.version=${projectVersion}
# Межсерверная догоняющая синхронизация личных сообщений.
dm.sync.enabled=true
dm.sync.initialDelaySeconds=60
dm.sync.periodHours=6
dm.sync.batchLimit=500
dm.sync.batchMaxBytes=3000000
dm.sync.maxPagesPerPeer=50
server.info.url=
server.info.physicalRegion=
server.info.description=
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.2.359
server.version=1.2.341
server.version=1.2.342
+1
View File
@@ -61,6 +61,7 @@
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
| `DmSyncBatch` | `12_Direct_Messages_Push_Calls_API.md` | межсерверная догоняющая синхронизация DM по курсору |
| `GetDirectMessages` | `12_Direct_Messages_Push_Calls_API.md` | постраничная загрузка истории личного диалога |
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
+73 -7
View File
@@ -10,7 +10,8 @@
Важно:
- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API;
- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`.
- для DM v1 нужно использовать `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`, `GetDirectMessages`;
- `DmSyncBatch` предназначен для межсерверной догоняющей синхронизации, не для обычного клиентского UI.
## 1. `UpsertPushToken`
@@ -139,11 +140,14 @@
"op": "ReceiveIncomingMessage",
"requestId": "dm-in-001",
"payload": {
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK"
"incomingBlobB64": "BASE64_INCOMING_SIGNED_BLOCK",
"sourceServerLogin": "server-a"
}
}
```
`sourceServerLogin` необязателен. Если поле есть, сервер использует его как подсказку, чтобы не отправлять событие обратно серверу-источнику.
## 5. `DeleteMessage`
Принимает один signed DM-блок `type=5` или `type=6`.
@@ -241,7 +245,69 @@
Для следующей страницы клиент должен передать `nextBeforeTimeMs` и `nextBeforeMessageKey` из предыдущего ответа.
## 8. `AckSessionDelivery`
## 8. `DmSyncBatch`
Межсерверная операция для догоняющей синхронизации истории одного пользователя. В текущей реализации не требует авторизации сервера-источника, но удалённый сервер отдаёт данные только если сам является access-сервером `ownerLogin` по `user_access_servers_current`.
### Запрос
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"payload": {
"ownerLogin": "alice",
"afterStoredAtMs": 1774700000000,
"afterMessageKey": "alice|bob|1774699999000|123456780|2",
"limit": 500,
"maxBytes": 3000000
}
}
```
`afterStoredAtMs` и `afterMessageKey` образуют курсор. Если курсора нет, сервер передаёт `0` и пустую строку. `limit` ограничен максимумом `500`.
### Успешный ответ
```json
{
"op": "DmSyncBatch",
"requestId": "dm-sync-001",
"status": 200,
"ok": true,
"payload": {
"ownerLogin": "alice",
"limit": 500,
"rawBytes": 84512,
"hasMore": true,
"nextStoredAtMs": 1774700100000,
"nextMessageKey": "alice|bob|1774700000123|123456789|1",
"items": [
{
"messageKey": "alice|bob|1774700000123|123456789|1",
"baseKey": "alice|bob|1774700000123|123456789",
"targetLogin": "alice",
"fromLogin": "bob",
"toLogin": "alice",
"messageType": 1,
"timeMs": 1774700000123,
"storedAtMs": 1774700100000,
"blobB64": "BASE64_SIGNED_BLOCK"
}
]
}
}
```
События в `items` идут по `storedAtMs ASC, messageKey ASC`. В пачке могут быть сообщения любых диалогов пользователя, read-receipt и delete/tombstone типов `5/6/7/8`.
Ошибки:
- `400 / EMPTY_OWNER_LOGIN` — не передан `ownerLogin`
- `403 / LOCAL_SERVER_NOT_ACCESS_SERVER` — этот сервер не является access-сервером пользователя
- `500 / LOCAL_SERVER_NOT_CONFIGURED` — не настроен `server.SHiNE.login`
## 9. `AckSessionDelivery`
Требует авторизации. Подтверждает доставку в текущую сессию.
@@ -257,7 +323,7 @@
}
```
## 9. Событие `SignedMessageArrived`
## 10. Событие `SignedMessageArrived`
Сервер присылает его по WebSocket в активные сессии адресата.
@@ -282,15 +348,15 @@
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
## 10. `CallInviteBroadcast`
## 11. `CallInviteBroadcast`
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
## 10. `CallSignalToSession`
## 12. `CallSignalToSession`
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
## 11. Замечания
## 13. Замечания
- все DM-типы `1..8` используют `SHiNE_DM`
- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE
@@ -446,11 +446,14 @@ Request:
"op": "ReceiveIncomingMessage",
"requestId": "req-456",
"payload": {
"incomingBlobB64": "..."
"incomingBlobB64": "...",
"sourceServerLogin": "server-a"
}
}
```
`sourceServerLogin` необязателен и используется как best-effort подсказка, чтобы сервер при дальнейшей пересылке не отправлял то же событие обратно серверу-источнику.
### 10.3. `DeleteMessage`
Назначение:
@@ -559,7 +562,50 @@ UI-следствие для клиента:
Идемпотентность обязательна.
### 11.3. Ошибки доставки
### 11.3. Догоняющая синхронизация истории
Для восстановления пропущенных DM-событий между access-серверами используется отдельная операция:
- `DmSyncBatch`
Сервер-получатель синхронизации запрашивает у другого access-сервера историю одного пользователя по курсору:
- `ownerLogin`;
- `afterStoredAtMs`;
- `afterMessageKey`;
- `limit`, максимум `500`;
- `maxBytes`, ограничение суммарного размера raw-блоков пачки.
Удалённый сервер отдаёт все DM-события, относящиеся к этому пользователю:
- контентные копии и read-receipt по `target_login`;
- tombstone типов `5/6/7/8`, где пользователь участвует как `fromLogin` или `toLogin`.
Порядок пачки:
- `created_at_ms ASC`;
- `message_key ASC`.
Курсор хранится локально для пары:
- пользователь;
- удалённый access-сервер.
При первом добавлении сервера или отсутствии курсора синхронизация стартует с `0` и постепенно подтягивает всю доступную историю пачками.
При применении событий, полученных через `DmSyncBatch`, сервер:
- проверяет формат `SHiNE_DM`;
- проверяет подпись;
- применяет существующие правила ревизий, read-receipt и tombstone;
- не отправляет realtime/push-уведомления клиентам;
- не запускает повторный fan-out, чтобы не создавать циклы.
Плановый sync запускается фоном после старта WebSocket-сервера и повторяется раз в 6 часов.
В текущей реализации межсерверная авторизация для `DmSyncBatch` ещё не включена. Сервер отдаёт пачку только если сам локально является access-сервером `ownerLogin` по актуальной таблице `user_access_servers_current`.
### 11.4. Ошибки доставки
Если часть серверов временно недоступна:
@@ -571,7 +617,7 @@ UI-следствие для клиента:
Основная таблица остаётся:
- `signed_messages_v2`
- `signed_messages`
В ней должны сохраняться:
@@ -590,6 +636,12 @@ UI-следствие для клиента:
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
Для догоняющей межсерверной синхронизации дополнительно используются:
- индекс по `target_login`, `created_at_ms`, `message_key`;
- отдельные индексы по delete-событиям для `from_login` и `to_login`;
- таблица `dm_sync_peer_state` с курсором чтения для пары `ownerLogin + remoteServerLogin`.
## 13. Что обязательно должно измениться в коде относительно v0.5
- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`;
@@ -610,4 +662,4 @@ UI-следствие для клиента:
- хранение отдельного `keyId` шифрования в DM;
- ротация `clientKey`;
- финальная конкретная UI-реализация массовой перешифровки;
- физическая полная реализация DM federation в текущем коде.
- межсерверная авторизация `DmSyncBatch`.
@@ -327,3 +327,5 @@ ReadReceiptBody_v1_0
## 13. Примечание о поддержке
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
Межсерверная операция `DmSyncBatch` не вводит новый байтовый формат DM. Она передаёт уже сохранённые raw-контейнеры `SHiNE_DM` в Base64 вместе с серверными метаданными курсора (`storedAtMs`, `messageKey`), а принимающий сервер заново проверяет подпись и применяет тот же контейнер по его `messageType`.