mindoodb 0.0.53 → 0.0.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-server.md +125 -69
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +17 -0
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +22 -0
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +58 -0
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +156 -3
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
- package/dist/browser/index.mjs +2 -0
- package/dist/core/BaseMindooDB.d.ts +73 -74
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +263 -509
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/MindooDBServerAdmin.d.ts +44 -22
- package/dist/core/MindooDBServerAdmin.d.ts.map +1 -1
- package/dist/core/MindooDBServerAdmin.js +45 -21
- package/dist/core/MindooDBServerAdmin.js.map +1 -1
- package/dist/core/appendonlystores/syncStores.d.ts +147 -0
- package/dist/core/appendonlystores/syncStores.d.ts.map +1 -0
- package/dist/core/appendonlystores/syncStores.js +612 -0
- package/dist/core/appendonlystores/syncStores.js.map +1 -0
- package/dist/core/builtinDbInvariants.d.ts +30 -0
- package/dist/core/builtinDbInvariants.d.ts.map +1 -1
- package/dist/core/builtinDbInvariants.js +97 -0
- package/dist/core/builtinDbInvariants.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +7 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/types.d.ts +42 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/node/server/CapabilityMatcher.d.ts +2 -2
- package/dist/node/server/CapabilityMatcher.js +2 -2
- package/dist/node/server/MindooDBServer.d.ts +14 -1
- package/dist/node/server/MindooDBServer.d.ts.map +1 -1
- package/dist/node/server/MindooDBServer.js +166 -159
- package/dist/node/server/MindooDBServer.js.map +1 -1
- package/dist/node/server/SyncEventBus.d.ts +10 -0
- package/dist/node/server/SyncEventBus.d.ts.map +1 -1
- package/dist/node/server/SyncEventBus.js.map +1 -1
- package/dist/node/server/TenantManager.d.ts +58 -6
- package/dist/node/server/TenantManager.d.ts.map +1 -1
- package/dist/node/server/TenantManager.js +90 -65
- package/dist/node/server/TenantManager.js.map +1 -1
- package/dist/node/server/add-to-network.d.ts.map +1 -1
- package/dist/node/server/add-to-network.js +33 -15
- package/dist/node/server/add-to-network.js.map +1 -1
- package/dist/node/server/canonicalSetupName.d.ts +22 -0
- package/dist/node/server/canonicalSetupName.d.ts.map +1 -0
- package/dist/node/server/canonicalSetupName.js +38 -0
- package/dist/node/server/canonicalSetupName.js.map +1 -0
- package/dist/node/server/config.d.ts.map +1 -1
- package/dist/node/server/config.js +33 -1
- package/dist/node/server/config.js.map +1 -1
- package/dist/node/server/peer/ClusterAuditLog.d.ts +64 -0
- package/dist/node/server/peer/ClusterAuditLog.d.ts.map +1 -0
- package/dist/node/server/peer/ClusterAuditLog.js +99 -0
- package/dist/node/server/peer/ClusterAuditLog.js.map +1 -0
- package/dist/node/server/peer/ClusterJobRunner.d.ts +43 -0
- package/dist/node/server/peer/ClusterJobRunner.d.ts.map +1 -0
- package/dist/node/server/peer/ClusterJobRunner.js +119 -0
- package/dist/node/server/peer/ClusterJobRunner.js.map +1 -0
- package/dist/node/server/peer/ClusterManager.d.ts +116 -0
- package/dist/node/server/peer/ClusterManager.d.ts.map +1 -0
- package/dist/node/server/peer/ClusterManager.js +274 -0
- package/dist/node/server/peer/ClusterManager.js.map +1 -0
- package/dist/node/server/peer/PeerAuthService.d.ts +69 -0
- package/dist/node/server/peer/PeerAuthService.d.ts.map +1 -0
- package/dist/node/server/peer/PeerAuthService.js +208 -0
- package/dist/node/server/peer/PeerAuthService.js.map +1 -0
- package/dist/node/server/peer/PeerReplicator.d.ts +232 -0
- package/dist/node/server/peer/PeerReplicator.d.ts.map +1 -0
- package/dist/node/server/peer/PeerReplicator.js +879 -0
- package/dist/node/server/peer/PeerReplicator.js.map +1 -0
- package/dist/node/server/peer/routes.d.ts +66 -0
- package/dist/node/server/peer/routes.d.ts.map +1 -0
- package/dist/node/server/peer/routes.js +322 -0
- package/dist/node/server/peer/routes.js.map +1 -0
- package/dist/node/server/peer/types.d.ts +244 -0
- package/dist/node/server/peer/types.d.ts.map +1 -0
- package/dist/node/server/peer/types.js +189 -0
- package/dist/node/server/peer/types.js.map +1 -0
- package/dist/node/server/server.d.ts +1 -1
- package/dist/node/server/server.js +18 -37
- package/dist/node/server/server.js.map +1 -1
- package/dist/node/server/serverinit.d.ts +2 -1
- package/dist/node/server/serverinit.d.ts.map +1 -1
- package/dist/node/server/serverinit.js +34 -12
- package/dist/node/server/serverinit.js.map +1 -1
- package/dist/node/server/types.d.ts +42 -30
- package/dist/node/server/types.d.ts.map +1 -1
- package/dist/node/server/types.js +1 -7
- package/dist/node/server/types.js.map +1 -1
- package/dist/node/server/validation.d.ts +0 -6
- package/dist/node/server/validation.d.ts.map +1 -1
- package/dist/node/server/validation.js +1 -7
- package/dist/node/server/validation.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Server-to-server replication with one trusted peer.
|
|
4
|
+
*
|
|
5
|
+
* One replicator instance per entry in `trusted-servers.json` that carries a
|
|
6
|
+
* `url`. It owns everything about that link: the peer session, the set of
|
|
7
|
+
* tenants both sides hold, the mirror loop over every database, the live push
|
|
8
|
+
* out of {@link SyncEventBus}, and the SSE subscription that pulls the other
|
|
9
|
+
* direction.
|
|
10
|
+
*
|
|
11
|
+
* ## What it replicates
|
|
12
|
+
*
|
|
13
|
+
* Everything both servers already have in common, and nothing else. The tenant
|
|
14
|
+
* set is intersected via a Bloom summary (see {@link refreshIntersection}), so
|
|
15
|
+
* a server never learns which tenants it does *not* host on the other side. For
|
|
16
|
+
* each shared tenant it mirrors **all** databases — the union of the local and
|
|
17
|
+
* remote database lists — in both stores (`docs` and `attachments`).
|
|
18
|
+
*
|
|
19
|
+
* ## Convergence invariants
|
|
20
|
+
*
|
|
21
|
+
* 1. **Directory first.** Databases are ordered by
|
|
22
|
+
* {@link orderDatabasesForReplication}: `directory`, then `userdirectory`,
|
|
23
|
+
* then the rest. An application entry signed by a key the peer does not
|
|
24
|
+
* trust yet is rejected per entry, so the grants have to land first.
|
|
25
|
+
* 2. **Cursor holds on rejection.** Unlike a client, a peer must not skip past
|
|
26
|
+
* a rejected entry — that is permanent divergence between two servers that
|
|
27
|
+
* are supposed to be identical. The peer path passes
|
|
28
|
+
* `rejectionPolicy: "hold"`, which clamps the cursor so the next run
|
|
29
|
+
* re-offers the entry once the missing trust has replicated.
|
|
30
|
+
* 3. **Receipts are preserved, not re-stamped.** Enforced on the receiving side
|
|
31
|
+
* in `ServerNetworkContentAddressedStore`; this side just transports.
|
|
32
|
+
*/
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.PeerReplicator = void 0;
|
|
35
|
+
exports.rollUpHealth = rollUpHealth;
|
|
36
|
+
const HttpTransport_1 = require("../../../appendonlystores/network/HttpTransport");
|
|
37
|
+
const ClientNetworkContentAddressedStore_1 = require("../../../appendonlystores/network/ClientNetworkContentAddressedStore");
|
|
38
|
+
const types_1 = require("../../../core/types");
|
|
39
|
+
const types_2 = require("../../../core/appendonlystores/network/types");
|
|
40
|
+
const logging_1 = require("../../../core/logging");
|
|
41
|
+
const bloom_1 = require("../../../core/appendonlystores/bloom");
|
|
42
|
+
const syncStores_1 = require("../../../core/appendonlystores/syncStores");
|
|
43
|
+
const types_3 = require("./types");
|
|
44
|
+
/** Token lifetime is an hour on the server; refresh well before that. */
|
|
45
|
+
const PEER_TOKEN_REFRESH_MS = 45 * 60 * 1000;
|
|
46
|
+
const RECONNECT_BASE_MS = 1000;
|
|
47
|
+
const RECONNECT_MAX_MS = 60000;
|
|
48
|
+
const IDLE_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
|
49
|
+
/** Live pushes are coalesced over this window so a burst becomes one run. */
|
|
50
|
+
const PUSH_COALESCE_MS = 250;
|
|
51
|
+
class PeerReplicator {
|
|
52
|
+
constructor(peer, host) {
|
|
53
|
+
this.running = false;
|
|
54
|
+
this.paused = false;
|
|
55
|
+
this.sessionState = "disabled";
|
|
56
|
+
this.eventsConnected = false;
|
|
57
|
+
this.token = null;
|
|
58
|
+
this.tokenIssuedAt = 0;
|
|
59
|
+
this.tokenPromise = null;
|
|
60
|
+
this.intersection = [];
|
|
61
|
+
this.intersectionAt = null;
|
|
62
|
+
this.tenants = new Map();
|
|
63
|
+
this.transferredEntries = 0;
|
|
64
|
+
this.transferredBytes = 0;
|
|
65
|
+
this.syncInFlight = null;
|
|
66
|
+
this.pendingScopes = new Set();
|
|
67
|
+
this.pushTimer = null;
|
|
68
|
+
this.idleTimer = null;
|
|
69
|
+
this.reconnectTimer = null;
|
|
70
|
+
this.reconnectAttempt = 0;
|
|
71
|
+
this.sseAbort = null;
|
|
72
|
+
this.unsubscribeBus = null;
|
|
73
|
+
this.lastError = null;
|
|
74
|
+
this.lastErrorClass = null;
|
|
75
|
+
this.lastErrorAt = null;
|
|
76
|
+
this.lastSyncAt = null;
|
|
77
|
+
/**
|
|
78
|
+
* Scan cursors, per (source, target) store pair.
|
|
79
|
+
*
|
|
80
|
+
* In-process only, deliberately: a restarted server pays one metadata scan
|
|
81
|
+
* per pair and then re-establishes the fast path from the store head. Cheap
|
|
82
|
+
* enough, and it avoids a second on-disk format to keep consistent with the
|
|
83
|
+
* stores it describes.
|
|
84
|
+
*/
|
|
85
|
+
this.cursors = new Map();
|
|
86
|
+
this.peer = peer;
|
|
87
|
+
this.host = host;
|
|
88
|
+
const settings = (0, types_3.normalizePeerSettings)(peer);
|
|
89
|
+
this.direction = settings.direction;
|
|
90
|
+
this.role = settings.role;
|
|
91
|
+
this.attachments = settings.attachments;
|
|
92
|
+
this.logger =
|
|
93
|
+
host.logger?.createChild(`Peer:${peer.name}`) ??
|
|
94
|
+
new logging_1.MindooLogger((0, logging_1.getDefaultLogLevel)(), `Peer:${peer.name}`, true);
|
|
95
|
+
}
|
|
96
|
+
get name() {
|
|
97
|
+
return this.peer.name;
|
|
98
|
+
}
|
|
99
|
+
get url() {
|
|
100
|
+
return this.peer.url;
|
|
101
|
+
}
|
|
102
|
+
/** Replace the peer config in place (peer CRUD) without dropping cursors. */
|
|
103
|
+
updatePeer(peer) {
|
|
104
|
+
this.peer = peer;
|
|
105
|
+
const settings = (0, types_3.normalizePeerSettings)(peer);
|
|
106
|
+
this.direction = settings.direction;
|
|
107
|
+
this.role = settings.role;
|
|
108
|
+
this.attachments = settings.attachments;
|
|
109
|
+
// Identity or endpoint may have changed; force a fresh session.
|
|
110
|
+
this.token = null;
|
|
111
|
+
this.tokenIssuedAt = 0;
|
|
112
|
+
}
|
|
113
|
+
/** Roles and endpoint, for `GET /system/cluster/topology`. */
|
|
114
|
+
topologyEntry() {
|
|
115
|
+
return {
|
|
116
|
+
name: this.peer.name,
|
|
117
|
+
url: this.peer.url ?? null,
|
|
118
|
+
direction: this.direction,
|
|
119
|
+
role: this.role,
|
|
120
|
+
attachments: this.attachments,
|
|
121
|
+
sessionActive: this.running && !this.paused && this.sessionState === "connected",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
start() {
|
|
125
|
+
if (this.running)
|
|
126
|
+
return;
|
|
127
|
+
if (!this.peer.url) {
|
|
128
|
+
this.logger.debug(`No url configured, replication disabled`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (this.direction === "disabled") {
|
|
132
|
+
this.sessionState = "disabled";
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!(0, types_3.shouldOpenSessionTo)(this.host.localRole, this.role)) {
|
|
136
|
+
// Spoke to spoke: the hub already carries this traffic. The trust entry
|
|
137
|
+
// stays so the peer can still authenticate inbound.
|
|
138
|
+
this.sessionState = "disabled";
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.running = true;
|
|
142
|
+
this.sessionState = "connecting";
|
|
143
|
+
this.unsubscribeBus = this.host.eventBus.subscribe((event) => {
|
|
144
|
+
this.onLocalChange(event);
|
|
145
|
+
});
|
|
146
|
+
this.idleTimer = setInterval(() => {
|
|
147
|
+
void this.syncNow().catch(() => {
|
|
148
|
+
// Errors are already recorded on the status snapshot.
|
|
149
|
+
});
|
|
150
|
+
}, IDLE_SYNC_INTERVAL_MS);
|
|
151
|
+
this.idleTimer.unref?.();
|
|
152
|
+
void this.bootstrap();
|
|
153
|
+
}
|
|
154
|
+
async stop() {
|
|
155
|
+
this.running = false;
|
|
156
|
+
this.unsubscribeBus?.();
|
|
157
|
+
this.unsubscribeBus = null;
|
|
158
|
+
if (this.pushTimer)
|
|
159
|
+
clearTimeout(this.pushTimer);
|
|
160
|
+
if (this.idleTimer)
|
|
161
|
+
clearInterval(this.idleTimer);
|
|
162
|
+
if (this.reconnectTimer)
|
|
163
|
+
clearTimeout(this.reconnectTimer);
|
|
164
|
+
this.pushTimer = null;
|
|
165
|
+
this.idleTimer = null;
|
|
166
|
+
this.reconnectTimer = null;
|
|
167
|
+
this.sseAbort?.abort();
|
|
168
|
+
this.sseAbort = null;
|
|
169
|
+
this.eventsConnected = false;
|
|
170
|
+
this.sessionState = "disabled";
|
|
171
|
+
try {
|
|
172
|
+
await this.syncInFlight;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// A sync failing during shutdown is not interesting.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
pause() {
|
|
179
|
+
this.paused = true;
|
|
180
|
+
this.sessionState = "paused";
|
|
181
|
+
this.sseAbort?.abort();
|
|
182
|
+
this.sseAbort = null;
|
|
183
|
+
this.eventsConnected = false;
|
|
184
|
+
}
|
|
185
|
+
resume() {
|
|
186
|
+
if (!this.paused)
|
|
187
|
+
return;
|
|
188
|
+
this.paused = false;
|
|
189
|
+
this.sessionState = "connecting";
|
|
190
|
+
this.reconnectAttempt = 0;
|
|
191
|
+
void this.bootstrap();
|
|
192
|
+
}
|
|
193
|
+
async bootstrap() {
|
|
194
|
+
if (!this.running || this.paused)
|
|
195
|
+
return;
|
|
196
|
+
try {
|
|
197
|
+
await this.refreshIntersection();
|
|
198
|
+
await this.syncNow();
|
|
199
|
+
this.startEventStream();
|
|
200
|
+
this.reconnectAttempt = 0;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
this.recordError(error);
|
|
204
|
+
this.scheduleReconnect();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
scheduleReconnect() {
|
|
208
|
+
if (!this.running || this.paused || this.reconnectTimer)
|
|
209
|
+
return;
|
|
210
|
+
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(this.reconnectAttempt, 6));
|
|
211
|
+
this.reconnectAttempt += 1;
|
|
212
|
+
this.sessionState = "backoff";
|
|
213
|
+
this.reconnectTimer = setTimeout(() => {
|
|
214
|
+
this.reconnectTimer = null;
|
|
215
|
+
void this.bootstrap();
|
|
216
|
+
}, delay);
|
|
217
|
+
this.reconnectTimer.unref?.();
|
|
218
|
+
}
|
|
219
|
+
// ---------------------------------------------------------------- session
|
|
220
|
+
async ensureToken() {
|
|
221
|
+
const fresh = this.token && Date.now() - this.tokenIssuedAt < PEER_TOKEN_REFRESH_MS;
|
|
222
|
+
if (fresh)
|
|
223
|
+
return this.token;
|
|
224
|
+
if (this.tokenPromise)
|
|
225
|
+
return this.tokenPromise;
|
|
226
|
+
this.tokenPromise = this.authenticate().finally(() => {
|
|
227
|
+
this.tokenPromise = null;
|
|
228
|
+
});
|
|
229
|
+
return this.tokenPromise;
|
|
230
|
+
}
|
|
231
|
+
async authenticate() {
|
|
232
|
+
const signingKey = await this.host.getSigningKey();
|
|
233
|
+
if (!signingKey) {
|
|
234
|
+
throw new Error("Server identity is locked; cannot authenticate with peer");
|
|
235
|
+
}
|
|
236
|
+
this.sessionState = "connecting";
|
|
237
|
+
// The peer looks us up by signing key, not by name: the key is the identity
|
|
238
|
+
// in `trusted-servers.json`, the name is only a label.
|
|
239
|
+
const challengeResponse = await this.peerFetch("/system/peer/challenge", {
|
|
240
|
+
method: "POST",
|
|
241
|
+
body: { publicsignkey: this.host.localSigningPublicKey },
|
|
242
|
+
authenticated: false,
|
|
243
|
+
});
|
|
244
|
+
const challenge = challengeResponse.challenge;
|
|
245
|
+
if (!challenge) {
|
|
246
|
+
throw new Error("Peer did not return a challenge");
|
|
247
|
+
}
|
|
248
|
+
const subtle = this.host.cryptoAdapter.getSubtle();
|
|
249
|
+
const signature = await subtle.sign({ name: "Ed25519" }, signingKey, new TextEncoder().encode(challenge));
|
|
250
|
+
const authResponse = (await this.peerFetch("/system/peer/authenticate", {
|
|
251
|
+
method: "POST",
|
|
252
|
+
body: {
|
|
253
|
+
challenge,
|
|
254
|
+
signature: Buffer.from(new Uint8Array(signature)).toString("base64"),
|
|
255
|
+
},
|
|
256
|
+
authenticated: false,
|
|
257
|
+
}));
|
|
258
|
+
if (!authResponse.success || !authResponse.token) {
|
|
259
|
+
throw new Error(authResponse.error || "Peer authentication failed");
|
|
260
|
+
}
|
|
261
|
+
this.token = authResponse.token;
|
|
262
|
+
this.tokenIssuedAt = Date.now();
|
|
263
|
+
this.sessionState = "connected";
|
|
264
|
+
return this.token;
|
|
265
|
+
}
|
|
266
|
+
async peerFetch(path, init = {}) {
|
|
267
|
+
const base = this.peer.url;
|
|
268
|
+
if (!base)
|
|
269
|
+
throw new Error(`Peer ${this.peer.name} has no url`);
|
|
270
|
+
const headers = { "Content-Type": "application/json" };
|
|
271
|
+
if (init.authenticated !== false) {
|
|
272
|
+
headers.Authorization = `Bearer ${await this.ensureToken()}`;
|
|
273
|
+
}
|
|
274
|
+
const response = await fetch(`${base.replace(/\/$/, "")}${path}`, {
|
|
275
|
+
method: init.method ?? "GET",
|
|
276
|
+
headers,
|
|
277
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
278
|
+
signal: init.signal,
|
|
279
|
+
});
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
const text = await response.text().catch(() => "");
|
|
282
|
+
if (response.status === 401 || response.status === 403) {
|
|
283
|
+
// Force a re-handshake on the next call: the peer may have rotated its
|
|
284
|
+
// trusted-servers entry or restarted with a new challenge store.
|
|
285
|
+
this.token = null;
|
|
286
|
+
}
|
|
287
|
+
throw new Error(`Peer ${this.peer.name} ${path} failed (${response.status}): ${text}`);
|
|
288
|
+
}
|
|
289
|
+
return response.json();
|
|
290
|
+
}
|
|
291
|
+
// ----------------------------------------------------------- intersection
|
|
292
|
+
/**
|
|
293
|
+
* Recompute the set of tenants both servers host.
|
|
294
|
+
*
|
|
295
|
+
* We send a Bloom summary of our tenant ids; the peer answers with the ids on
|
|
296
|
+
* *its* side that the filter might contain. Those are candidates only — a
|
|
297
|
+
* Bloom filter has false positives — so the result is intersected against the
|
|
298
|
+
* authoritative local list before anything is replicated. That second step is
|
|
299
|
+
* what makes the false-positive rate a performance question rather than a
|
|
300
|
+
* correctness one.
|
|
301
|
+
*/
|
|
302
|
+
async refreshIntersection() {
|
|
303
|
+
const localTenants = this.host.listTenants();
|
|
304
|
+
const localSet = new Set(localTenants);
|
|
305
|
+
const summary = (0, bloom_1.createIdBloomSummary)(localTenants);
|
|
306
|
+
const response = (await this.peerFetch("/system/peer/tenant-bloom", {
|
|
307
|
+
method: "POST",
|
|
308
|
+
body: { bloom: summary },
|
|
309
|
+
}));
|
|
310
|
+
const candidates = Array.isArray(response.tenantIds) ? response.tenantIds : [];
|
|
311
|
+
const shared = candidates.filter((tenantId) => localSet.has(tenantId)).sort();
|
|
312
|
+
this.intersection = shared;
|
|
313
|
+
this.intersectionAt = new Date().toISOString();
|
|
314
|
+
for (const tenantId of this.tenants.keys()) {
|
|
315
|
+
if (!shared.includes(tenantId))
|
|
316
|
+
this.tenants.delete(tenantId);
|
|
317
|
+
}
|
|
318
|
+
for (const tenantId of shared) {
|
|
319
|
+
if (!this.tenants.has(tenantId)) {
|
|
320
|
+
this.tenants.set(tenantId, {
|
|
321
|
+
tenantId,
|
|
322
|
+
databases: [],
|
|
323
|
+
lastSyncAt: null,
|
|
324
|
+
lag: 0,
|
|
325
|
+
retryQueueDepth: 0,
|
|
326
|
+
rejectedEntries: 0,
|
|
327
|
+
errorClass: null,
|
|
328
|
+
suppressUpToPeerOrder: 0,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return shared;
|
|
333
|
+
}
|
|
334
|
+
// ------------------------------------------------------------------- sync
|
|
335
|
+
/**
|
|
336
|
+
* Run one full replication pass, optionally narrowed to a tenant or database.
|
|
337
|
+
*
|
|
338
|
+
* Concurrent calls collapse onto the in-flight run: the caller that arrives
|
|
339
|
+
* second gets the same promise rather than a second parallel mirror of the
|
|
340
|
+
* same stores.
|
|
341
|
+
*/
|
|
342
|
+
async syncNow(scope) {
|
|
343
|
+
if (this.syncInFlight)
|
|
344
|
+
return this.syncInFlight;
|
|
345
|
+
this.syncInFlight = this.runSync(scope).finally(() => {
|
|
346
|
+
this.syncInFlight = null;
|
|
347
|
+
});
|
|
348
|
+
return this.syncInFlight;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Re-offer entries the peer rejected.
|
|
352
|
+
*
|
|
353
|
+
* Under the hold policy the cursor is already clamped, so a plain sync run is
|
|
354
|
+
* the retry — this exists as a named admin action so the UI has something to
|
|
355
|
+
* call that says what it does.
|
|
356
|
+
*/
|
|
357
|
+
async retryRejected(scope) {
|
|
358
|
+
return this.syncNow(scope);
|
|
359
|
+
}
|
|
360
|
+
async runSync(scope) {
|
|
361
|
+
const result = {
|
|
362
|
+
peer: this.peer.name,
|
|
363
|
+
tenants: 0,
|
|
364
|
+
databases: 0,
|
|
365
|
+
pushed: 0,
|
|
366
|
+
pulled: 0,
|
|
367
|
+
rejected: 0,
|
|
368
|
+
held: 0,
|
|
369
|
+
errors: [],
|
|
370
|
+
};
|
|
371
|
+
if (this.paused || this.direction === "disabled" || !this.peer.url) {
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
if (this.intersection.length === 0) {
|
|
375
|
+
await this.refreshIntersection().catch((error) => {
|
|
376
|
+
this.recordError(error);
|
|
377
|
+
result.errors.push(errorMessage(error));
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const tenantIds = scope?.tenantId
|
|
381
|
+
? this.intersection.filter((id) => id === scope.tenantId)
|
|
382
|
+
: this.intersection;
|
|
383
|
+
for (const tenantId of tenantIds) {
|
|
384
|
+
const runtime = this.tenants.get(tenantId);
|
|
385
|
+
if (!runtime)
|
|
386
|
+
continue;
|
|
387
|
+
runtime.rejectedEntries = 0;
|
|
388
|
+
runtime.retryQueueDepth = 0;
|
|
389
|
+
runtime.errorClass = null;
|
|
390
|
+
try {
|
|
391
|
+
const databases = await this.databasesFor(tenantId, scope?.dbId);
|
|
392
|
+
// Only a full-tenant run knows the complete list; a scoped run must not
|
|
393
|
+
// shrink what the status endpoint reports.
|
|
394
|
+
if (!scope?.dbId)
|
|
395
|
+
runtime.databases = databases;
|
|
396
|
+
result.tenants += 1;
|
|
397
|
+
for (const [dbId, storeKind] of this.mirrorPlan(databases)) {
|
|
398
|
+
if (storeKind === types_1.StoreKind.docs)
|
|
399
|
+
result.databases += 1;
|
|
400
|
+
const pair = await this.syncStorePair(tenantId, dbId, storeKind);
|
|
401
|
+
result.pushed += pair.pushed;
|
|
402
|
+
result.pulled += pair.pulled;
|
|
403
|
+
result.rejected += pair.rejected;
|
|
404
|
+
runtime.rejectedEntries += pair.rejected;
|
|
405
|
+
this.transferredEntries += pair.pushed + pair.pulled;
|
|
406
|
+
this.transferredBytes += pair.bytes;
|
|
407
|
+
if (pair.held) {
|
|
408
|
+
runtime.retryQueueDepth += 1;
|
|
409
|
+
result.held += 1;
|
|
410
|
+
}
|
|
411
|
+
if (pair.deniedReason) {
|
|
412
|
+
// Visible in the status read-model as a backoff rather than an
|
|
413
|
+
// outage, so an operator can tell "waiting on a purge that has not
|
|
414
|
+
// replicated yet" from "this peer is unreachable".
|
|
415
|
+
runtime.errorClass = "access-denied";
|
|
416
|
+
this.lastError = pair.deniedReason;
|
|
417
|
+
this.lastErrorClass = "access-denied";
|
|
418
|
+
this.lastErrorAt = new Date().toISOString();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
runtime.lastSyncAt = new Date().toISOString();
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
const message = errorMessage(error);
|
|
425
|
+
runtime.errorClass = (0, types_3.classifyPeerError)(error);
|
|
426
|
+
result.errors.push(`${tenantId}: ${message}`);
|
|
427
|
+
this.recordError(error);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
this.lastSyncAt = new Date().toISOString();
|
|
431
|
+
// A held pair is not a clean run: the backoff has to stay visible until the
|
|
432
|
+
// entries actually land, or the status flips to healthy while the mirror is
|
|
433
|
+
// still incomplete.
|
|
434
|
+
if (result.errors.length === 0 && result.held === 0) {
|
|
435
|
+
this.lastError = null;
|
|
436
|
+
this.lastErrorClass = null;
|
|
437
|
+
this.lastErrorAt = null;
|
|
438
|
+
this.sessionState = "connected";
|
|
439
|
+
}
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* The (database, store) pairs to mirror for one tenant, in order.
|
|
444
|
+
*
|
|
445
|
+
* `eager` interleaves — each database's documents are immediately followed by
|
|
446
|
+
* its attachments, so a database is complete before the next one starts.
|
|
447
|
+
* `lazy` runs every document store first and only then the attachment stores:
|
|
448
|
+
* a run that is interrupted (or a link that is slow) has then spent its
|
|
449
|
+
* bandwidth on the text, which is what makes documents readable, rather than
|
|
450
|
+
* on blobs that can be fetched later. `never` skips attachments entirely.
|
|
451
|
+
*
|
|
452
|
+
* Database order comes from {@link orderDatabasesForReplication} either way —
|
|
453
|
+
* invariant 1 outranks the attachment policy.
|
|
454
|
+
*/
|
|
455
|
+
*mirrorPlan(databases) {
|
|
456
|
+
if (this.attachments === "eager") {
|
|
457
|
+
for (const dbId of databases) {
|
|
458
|
+
yield [dbId, types_1.StoreKind.docs];
|
|
459
|
+
yield [dbId, types_1.StoreKind.attachments];
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
for (const dbId of databases) {
|
|
464
|
+
yield [dbId, types_1.StoreKind.docs];
|
|
465
|
+
}
|
|
466
|
+
if (this.attachments === "never")
|
|
467
|
+
return;
|
|
468
|
+
for (const dbId of databases) {
|
|
469
|
+
yield [dbId, types_1.StoreKind.attachments];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Databases to mirror for a tenant: the union of both sides, in invariant
|
|
474
|
+
* order (invariant 1).
|
|
475
|
+
*
|
|
476
|
+
* Union rather than intersection, because a database that exists only on the
|
|
477
|
+
* peer is exactly the thing a fresh mirror needs to pull.
|
|
478
|
+
*/
|
|
479
|
+
async databasesFor(tenantId, onlyDbId) {
|
|
480
|
+
const local = this.host.listDatabases(tenantId);
|
|
481
|
+
let remote = [];
|
|
482
|
+
try {
|
|
483
|
+
const response = (await this.peerFetch(`/system/peer/databases?tenantId=${encodeURIComponent(tenantId)}`));
|
|
484
|
+
remote = Array.isArray(response.databases) ? response.databases : [];
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
this.logger.debug(`Could not list peer databases for ${tenantId}: ${errorMessage(error)}`);
|
|
488
|
+
}
|
|
489
|
+
const union = (0, types_3.orderDatabasesForReplication)(new Set([...local, ...remote]));
|
|
490
|
+
return onlyDbId ? union.filter((dbId) => dbId === onlyDbId) : union;
|
|
491
|
+
}
|
|
492
|
+
async syncStorePair(tenantId, dbId, storeKind) {
|
|
493
|
+
const localStore = await this.host.getLocalStore(tenantId, dbId, storeKind);
|
|
494
|
+
const remoteStore = await this.createRemoteStore(tenantId, dbId, storeKind);
|
|
495
|
+
let pushed = 0;
|
|
496
|
+
let pulled = 0;
|
|
497
|
+
let bytes = 0;
|
|
498
|
+
let rejected = 0;
|
|
499
|
+
let held = false;
|
|
500
|
+
let deniedReason;
|
|
501
|
+
// Both directions use `rejectionPolicy: "hold"` (invariant 2). A client may
|
|
502
|
+
// skip a poisoned entry forever; two servers that are meant to be identical
|
|
503
|
+
// may not, so the cursor stays clamped until the entry is accepted.
|
|
504
|
+
//
|
|
505
|
+
// A denial can also arrive for the batch as a whole rather than per entry —
|
|
506
|
+
// a purged document or a revoked decryption key makes the target refuse
|
|
507
|
+
// everything in the request. That is the same condition seen from further
|
|
508
|
+
// away: hold this pair, leave the cursor where it is, and carry on with the
|
|
509
|
+
// tenant's other databases. Treating it as a tenant-level failure would let
|
|
510
|
+
// one purged document stop the whole mirror.
|
|
511
|
+
if ((0, types_3.directionAllowsPull)(this.direction)) {
|
|
512
|
+
const result = await this.transfer(remoteStore, localStore);
|
|
513
|
+
pulled = result.transferred;
|
|
514
|
+
bytes += result.bytes;
|
|
515
|
+
rejected += result.rejected;
|
|
516
|
+
held || (held = result.held);
|
|
517
|
+
deniedReason ?? (deniedReason = result.deniedReason);
|
|
518
|
+
}
|
|
519
|
+
if ((0, types_3.directionAllowsPush)(this.direction)) {
|
|
520
|
+
const result = await this.transfer(localStore, remoteStore);
|
|
521
|
+
pushed = result.transferred;
|
|
522
|
+
bytes += result.bytes;
|
|
523
|
+
rejected += result.rejected;
|
|
524
|
+
held || (held = result.held);
|
|
525
|
+
deniedReason ?? (deniedReason = result.deniedReason);
|
|
526
|
+
if (pushed > 0) {
|
|
527
|
+
// Remember how far the peer's log advanced because of us, so its SSE
|
|
528
|
+
// notification for these very entries is recognised as our own echo.
|
|
529
|
+
const runtime = this.tenants.get(tenantId);
|
|
530
|
+
const head = await remoteStore.getStoreHead?.().catch(() => null);
|
|
531
|
+
if (runtime && head?.maxReceiptOrder !== undefined) {
|
|
532
|
+
runtime.suppressUpToPeerOrder = Math.max(runtime.suppressUpToPeerOrder, head.maxReceiptOrder);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { pushed, pulled, bytes, rejected, held, deniedReason };
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* One leg of a store pair, with a batch-wide denial folded into `held`.
|
|
540
|
+
*
|
|
541
|
+
* See {@link syncStorePair}: a denial means "not now", so the cursor must
|
|
542
|
+
* stay put and the pair is retried on the next run. Any other failure is a
|
|
543
|
+
* real error and propagates to the tenant loop.
|
|
544
|
+
*/
|
|
545
|
+
async transfer(source, target) {
|
|
546
|
+
try {
|
|
547
|
+
const result = await (0, syncStores_1.syncEntriesBetweenStores)(source, target, undefined, {
|
|
548
|
+
logger: this.logger,
|
|
549
|
+
cursors: this.cursorStore(),
|
|
550
|
+
rejectionPolicy: "hold",
|
|
551
|
+
});
|
|
552
|
+
return {
|
|
553
|
+
transferred: result.transferred,
|
|
554
|
+
bytes: result.transferredBytes ?? 0,
|
|
555
|
+
rejected: result.rejected?.length ?? 0,
|
|
556
|
+
held: result.cursorHeldForRetry === true,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if ((0, types_3.classifyPeerError)(error) !== "access-denied")
|
|
561
|
+
throw error;
|
|
562
|
+
this.logger.warn(`Peer refused a batch, holding for retry: ${errorMessage(error)}`);
|
|
563
|
+
return {
|
|
564
|
+
transferred: 0,
|
|
565
|
+
bytes: 0,
|
|
566
|
+
rejected: 0,
|
|
567
|
+
held: true,
|
|
568
|
+
deniedReason: (0, types_3.redactEntryIds)(errorMessage(error)),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async createRemoteStore(tenantId, dbId, storeKind) {
|
|
573
|
+
const encryptionKey = await this.host.getEncryptionKey();
|
|
574
|
+
if (!encryptionKey) {
|
|
575
|
+
throw new Error("Server identity is locked; cannot decrypt peer entries");
|
|
576
|
+
}
|
|
577
|
+
const signingKey = await this.host.getSigningKey();
|
|
578
|
+
if (!signingKey) {
|
|
579
|
+
throw new Error("Server identity is locked; cannot sign for peer");
|
|
580
|
+
}
|
|
581
|
+
// The tenant segment belongs in the base url, not just in the config: the
|
|
582
|
+
// transport builds `${baseUrl}/sync/${storeKind}/...` and the server routes
|
|
583
|
+
// sync under `/:tenantId/sync/*`. Passing the bare server url — as the old
|
|
584
|
+
// per-tenant ServerSync did — makes every request land on `/sync/...`,
|
|
585
|
+
// where Express reads "sync" as the tenant id and answers 404.
|
|
586
|
+
const transport = new HttpTransport_1.HttpTransport({
|
|
587
|
+
baseUrl: `${this.peer.url.replace(/\/$/, "")}/${encodeURIComponent(tenantId)}`,
|
|
588
|
+
tenantId,
|
|
589
|
+
dbId,
|
|
590
|
+
storeKind,
|
|
591
|
+
});
|
|
592
|
+
const store = new ClientNetworkContentAddressedStore_1.ClientNetworkContentAddressedStore(dbId, storeKind, transport, this.host.cryptoAdapter, this.host.localServerName, signingKey, encryptionKey, this.logger);
|
|
593
|
+
// The tenant handshake would fail: a peer holds no per-tenant grant. It
|
|
594
|
+
// carries the cluster-level peer token instead.
|
|
595
|
+
store.setExternalTokenProvider(() => this.ensureToken());
|
|
596
|
+
return store;
|
|
597
|
+
}
|
|
598
|
+
cursorStore() {
|
|
599
|
+
return {
|
|
600
|
+
get: (key) => this.cursors.get(key) ?? null,
|
|
601
|
+
save: (key, record) => {
|
|
602
|
+
this.cursors.set(key, record);
|
|
603
|
+
},
|
|
604
|
+
delete: (key) => {
|
|
605
|
+
this.cursors.delete(key);
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
// ------------------------------------------------------------- live push
|
|
610
|
+
onLocalChange(event) {
|
|
611
|
+
if (!this.running || this.paused)
|
|
612
|
+
return;
|
|
613
|
+
if (!(0, types_3.directionAllowsPush)(this.direction))
|
|
614
|
+
return;
|
|
615
|
+
// Do not bounce a peer's own write straight back at it. Other peers still
|
|
616
|
+
// receive it, which is how a spoke's write reaches the mesh via its hub.
|
|
617
|
+
if (event.originPeer === this.peer.name)
|
|
618
|
+
return;
|
|
619
|
+
if (!this.intersection.includes(event.tenantId))
|
|
620
|
+
return;
|
|
621
|
+
if (this.attachments === "never" && event.storeKind === types_1.StoreKind.attachments)
|
|
622
|
+
return;
|
|
623
|
+
this.pendingScopes.add(`${event.tenantId}\u0000${event.dbId}`);
|
|
624
|
+
if (this.pushTimer)
|
|
625
|
+
return;
|
|
626
|
+
this.pushTimer = setTimeout(() => {
|
|
627
|
+
this.pushTimer = null;
|
|
628
|
+
const scopes = [...this.pendingScopes];
|
|
629
|
+
this.pendingScopes.clear();
|
|
630
|
+
void this.flushScopes(scopes);
|
|
631
|
+
}, PUSH_COALESCE_MS);
|
|
632
|
+
this.pushTimer.unref?.();
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Round-robin over the pending (tenant, db) scopes.
|
|
636
|
+
*
|
|
637
|
+
* Sequential on purpose: a hub with many spokes should not open a hundred
|
|
638
|
+
* parallel sessions to one peer, and one busy tenant must not starve the
|
|
639
|
+
* others — each pending scope gets its turn in the order it was queued.
|
|
640
|
+
*/
|
|
641
|
+
async flushScopes(scopes) {
|
|
642
|
+
for (const scope of scopes) {
|
|
643
|
+
const [tenantId, dbId] = scope.split("\u0000");
|
|
644
|
+
try {
|
|
645
|
+
await this.syncNow({ tenantId, dbId });
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
this.recordError(error);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// -------------------------------------------------------------- SSE pull
|
|
653
|
+
startEventStream() {
|
|
654
|
+
if (!this.running || this.paused)
|
|
655
|
+
return;
|
|
656
|
+
if (!(0, types_3.directionAllowsPull)(this.direction))
|
|
657
|
+
return;
|
|
658
|
+
if (this.sseAbort)
|
|
659
|
+
return;
|
|
660
|
+
const controller = new AbortController();
|
|
661
|
+
this.sseAbort = controller;
|
|
662
|
+
void this.consumeEventStream(controller).catch((error) => {
|
|
663
|
+
if (!controller.signal.aborted) {
|
|
664
|
+
this.recordError(error);
|
|
665
|
+
this.scheduleReconnect();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
async consumeEventStream(controller) {
|
|
670
|
+
const base = this.peer.url;
|
|
671
|
+
const token = await this.ensureToken();
|
|
672
|
+
const response = await fetch(`${base.replace(/\/$/, "")}/system/peer/events`, {
|
|
673
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
|
|
674
|
+
signal: controller.signal,
|
|
675
|
+
});
|
|
676
|
+
if (!response.ok || !response.body) {
|
|
677
|
+
throw new Error(`Peer event stream failed (${response.status})`);
|
|
678
|
+
}
|
|
679
|
+
this.sessionState = "connected";
|
|
680
|
+
this.eventsConnected = true;
|
|
681
|
+
this.reconnectAttempt = 0;
|
|
682
|
+
const reader = response.body.getReader();
|
|
683
|
+
const decoder = new TextDecoder();
|
|
684
|
+
let buffer = "";
|
|
685
|
+
while (!controller.signal.aborted) {
|
|
686
|
+
const { done, value } = await reader.read();
|
|
687
|
+
if (done)
|
|
688
|
+
break;
|
|
689
|
+
buffer += decoder.decode(value, { stream: true });
|
|
690
|
+
let separator = buffer.indexOf("\n\n");
|
|
691
|
+
while (separator !== -1) {
|
|
692
|
+
const frame = buffer.slice(0, separator);
|
|
693
|
+
buffer = buffer.slice(separator + 2);
|
|
694
|
+
this.handleEventFrame(frame);
|
|
695
|
+
separator = buffer.indexOf("\n\n");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (this.sseAbort === controller) {
|
|
699
|
+
this.sseAbort = null;
|
|
700
|
+
this.eventsConnected = false;
|
|
701
|
+
// The peer closed the stream. Catch up once (entries may have landed
|
|
702
|
+
// while we were disconnected) and then re-subscribe.
|
|
703
|
+
if (this.running && !this.paused) {
|
|
704
|
+
void this.syncNow().catch(() => undefined);
|
|
705
|
+
this.scheduleReconnect();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
handleEventFrame(frame) {
|
|
710
|
+
const dataLine = frame
|
|
711
|
+
.split("\n")
|
|
712
|
+
.find((line) => line.startsWith("data:"));
|
|
713
|
+
if (!dataLine)
|
|
714
|
+
return;
|
|
715
|
+
let event;
|
|
716
|
+
try {
|
|
717
|
+
event = JSON.parse(dataLine.slice(5).trim());
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (!event.tenantId || !event.dbId)
|
|
723
|
+
return;
|
|
724
|
+
if (!this.intersection.includes(event.tenantId))
|
|
725
|
+
return;
|
|
726
|
+
if (this.attachments === "never" && event.storeKind === types_1.StoreKind.attachments)
|
|
727
|
+
return;
|
|
728
|
+
// Echo suppression: this notification describes entries we ourselves just
|
|
729
|
+
// pushed, so pulling them back would be a round trip for nothing.
|
|
730
|
+
const runtime = this.tenants.get(event.tenantId);
|
|
731
|
+
if (runtime &&
|
|
732
|
+
event.maxReceiptOrder !== undefined &&
|
|
733
|
+
event.maxReceiptOrder <= runtime.suppressUpToPeerOrder) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
this.pendingScopes.add(`${event.tenantId}\u0000${event.dbId}`);
|
|
737
|
+
if (this.pushTimer)
|
|
738
|
+
return;
|
|
739
|
+
this.pushTimer = setTimeout(() => {
|
|
740
|
+
this.pushTimer = null;
|
|
741
|
+
const scopes = [...this.pendingScopes];
|
|
742
|
+
this.pendingScopes.clear();
|
|
743
|
+
void this.flushScopes(scopes);
|
|
744
|
+
}, PUSH_COALESCE_MS);
|
|
745
|
+
this.pushTimer.unref?.();
|
|
746
|
+
}
|
|
747
|
+
// ------------------------------------------------------------- reporting
|
|
748
|
+
recordError(error) {
|
|
749
|
+
this.lastError = (0, types_3.redactEntryIds)(errorMessage(error));
|
|
750
|
+
this.lastErrorClass = (0, types_3.classifyPeerError)(error);
|
|
751
|
+
this.lastErrorAt = new Date().toISOString();
|
|
752
|
+
if (this.lastErrorClass === "network") {
|
|
753
|
+
this.sessionState = "unreachable";
|
|
754
|
+
}
|
|
755
|
+
else if (this.sessionState !== "paused" && this.sessionState !== "disabled") {
|
|
756
|
+
this.sessionState = "backoff";
|
|
757
|
+
}
|
|
758
|
+
this.logger.warn(`Replication error: ${this.lastError}`);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Status snapshot for `GET /system/cluster/status`.
|
|
762
|
+
*
|
|
763
|
+
* Read straight from in-memory replication state — no store scans — so the
|
|
764
|
+
* admin UI can poll it without competing with replication for I/O. Contains
|
|
765
|
+
* counts and classified reasons only: never a docId, never an author key.
|
|
766
|
+
*/
|
|
767
|
+
snapshot() {
|
|
768
|
+
const tenants = [...this.tenants.values()].map((runtime) => this.tenantStatus(runtime));
|
|
769
|
+
const { health, reasons } = rollUpHealth(tenants, this.peerLevelReasons());
|
|
770
|
+
return {
|
|
771
|
+
name: this.peer.name,
|
|
772
|
+
url: this.peer.url ?? null,
|
|
773
|
+
direction: this.direction,
|
|
774
|
+
role: this.role,
|
|
775
|
+
attachments: this.attachments,
|
|
776
|
+
state: this.sessionState,
|
|
777
|
+
health,
|
|
778
|
+
reasons,
|
|
779
|
+
sharedTenants: this.intersection.length,
|
|
780
|
+
lastIntersectionAt: this.intersectionAt,
|
|
781
|
+
lastSyncAt: this.lastSyncAt,
|
|
782
|
+
eventsConnected: this.eventsConnected,
|
|
783
|
+
lag: tenants.reduce((sum, tenant) => sum + tenant.lag, 0),
|
|
784
|
+
retryQueueDepth: tenants.reduce((sum, tenant) => sum + tenant.retryQueueDepth, 0),
|
|
785
|
+
rejectedEntries: tenants.reduce((sum, tenant) => sum + tenant.rejectedEntries, 0),
|
|
786
|
+
transferredEntries: this.transferredEntries,
|
|
787
|
+
transferredBytes: this.transferredBytes,
|
|
788
|
+
lastError: this.lastError,
|
|
789
|
+
lastErrorClass: this.lastErrorClass,
|
|
790
|
+
lastErrorAt: this.lastErrorAt,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
tenantSnapshot(tenantId) {
|
|
794
|
+
const runtime = this.tenants.get(tenantId);
|
|
795
|
+
return runtime ? this.tenantStatus(runtime) : null;
|
|
796
|
+
}
|
|
797
|
+
tenantStatus(runtime) {
|
|
798
|
+
const reasons = [];
|
|
799
|
+
if (runtime.retryQueueDepth > 0)
|
|
800
|
+
reasons.push("entries-awaiting-directory-trust");
|
|
801
|
+
if (runtime.errorClass === "access-denied")
|
|
802
|
+
reasons.push("access-denied-backoff");
|
|
803
|
+
const { health } = rollUpHealth([], reasons);
|
|
804
|
+
return {
|
|
805
|
+
tenantId: runtime.tenantId,
|
|
806
|
+
databases: runtime.databases,
|
|
807
|
+
lastSyncAt: runtime.lastSyncAt,
|
|
808
|
+
lag: runtime.lag,
|
|
809
|
+
retryQueueDepth: runtime.retryQueueDepth,
|
|
810
|
+
rejectedEntries: runtime.rejectedEntries,
|
|
811
|
+
health,
|
|
812
|
+
reasons,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
/** Reasons that come from the link itself rather than from any one tenant. */
|
|
816
|
+
peerLevelReasons() {
|
|
817
|
+
const reasons = [];
|
|
818
|
+
if (!this.peer.url)
|
|
819
|
+
reasons.push("no-url-configured");
|
|
820
|
+
if (this.direction === "disabled")
|
|
821
|
+
reasons.push("session-disabled");
|
|
822
|
+
if (this.paused)
|
|
823
|
+
reasons.push("session-paused");
|
|
824
|
+
if (this.sessionState === "unreachable")
|
|
825
|
+
reasons.push("peer-unreachable");
|
|
826
|
+
if (this.lastErrorClass === "auth")
|
|
827
|
+
reasons.push("peer-auth-failed");
|
|
828
|
+
if (this.running &&
|
|
829
|
+
!this.paused &&
|
|
830
|
+
!this.eventsConnected &&
|
|
831
|
+
(0, types_3.directionAllowsPull)(this.direction)) {
|
|
832
|
+
reasons.push("events-disconnected");
|
|
833
|
+
}
|
|
834
|
+
if (this.intersectionAt === null && this.running)
|
|
835
|
+
reasons.push("intersection-stale");
|
|
836
|
+
return reasons;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
exports.PeerReplicator = PeerReplicator;
|
|
840
|
+
/**
|
|
841
|
+
* Collapse reasons into one health verdict, shared by the per-tenant, per-peer
|
|
842
|
+
* and per-node rollups so all three agree on the thresholds.
|
|
843
|
+
*
|
|
844
|
+
* `stalled` outranks `degraded` deliberately. A clamped cursor is the visible
|
|
845
|
+
* precursor of divergence: the link looks healthy, entries keep flowing, and
|
|
846
|
+
* yet one database is quietly stuck behind an entry the peer will not accept
|
|
847
|
+
* until the directory catches up. That is the state an operator most needs to
|
|
848
|
+
* see, so a transient error elsewhere must not mask it.
|
|
849
|
+
*/
|
|
850
|
+
function rollUpHealth(tenants, extraReasons = []) {
|
|
851
|
+
const reasons = new Set(extraReasons);
|
|
852
|
+
for (const tenant of tenants) {
|
|
853
|
+
for (const reason of tenant.reasons)
|
|
854
|
+
reasons.add(reason);
|
|
855
|
+
}
|
|
856
|
+
let health = "ok";
|
|
857
|
+
if (reasons.has("session-disabled") || reasons.has("session-paused")) {
|
|
858
|
+
// Deliberately off is not unhealthy; it is just not running.
|
|
859
|
+
health = "ok";
|
|
860
|
+
}
|
|
861
|
+
else if (reasons.has("peer-unreachable") ||
|
|
862
|
+
reasons.has("peer-auth-failed") ||
|
|
863
|
+
reasons.has("no-url-configured")) {
|
|
864
|
+
health = "down";
|
|
865
|
+
}
|
|
866
|
+
else if (reasons.has("entries-awaiting-directory-trust")) {
|
|
867
|
+
health = "stalled";
|
|
868
|
+
}
|
|
869
|
+
else if (reasons.size > 0) {
|
|
870
|
+
health = "degraded";
|
|
871
|
+
}
|
|
872
|
+
return { health, reasons: [...reasons] };
|
|
873
|
+
}
|
|
874
|
+
function errorMessage(error) {
|
|
875
|
+
if (error instanceof types_2.NetworkError)
|
|
876
|
+
return `${error.type}: ${error.message}`;
|
|
877
|
+
return error instanceof Error ? error.message : String(error);
|
|
878
|
+
}
|
|
879
|
+
//# sourceMappingURL=PeerReplicator.js.map
|