SHA256
Добавить догоняющую синхронизацию DM между access-серверами
This commit is contained in:
@@ -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;
|
||||
|
||||
+20
@@ -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"));
|
||||
|
||||
+32
@@ -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,
|
||||
|
||||
+4
@@ -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),
|
||||
|
||||
+50
@@ -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
|
||||
) {}
|
||||
}
|
||||
+114
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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; }
|
||||
}
|
||||
+62
@@ -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; }
|
||||
}
|
||||
}
|
||||
+60
@@ -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
@@ -1,2 +1,2 @@
|
||||
client.version=1.2.359
|
||||
server.version=1.2.341
|
||||
server.version=1.2.342
|
||||
|
||||
@@ -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 приглашения к звонку |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user