@delali/sirannon-db 0.1.3 → 0.1.5
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.md +655 -80
- package/dist/backup-scheduler/index.d.ts +3 -0
- package/dist/backup-scheduler/index.mjs +2 -0
- package/dist/change-tracker-CFTQ9TSn.d.ts +89 -0
- package/dist/chunk-3MCMONVP.mjs +115 -0
- package/dist/chunk-74UN4DIE.mjs +14 -0
- package/dist/chunk-ER7ODTDA.mjs +23 -0
- package/dist/chunk-FB2U2Q3Y.mjs +21 -0
- package/dist/chunk-GS7T5YMI.mjs +51 -0
- package/dist/chunk-O7BHI3CF.mjs +90 -0
- package/dist/chunk-PXKAKK2V.mjs +124 -0
- package/dist/chunk-UTO3ZAFS.mjs +514 -0
- package/dist/chunk-UVMVN3OT.mjs +111 -0
- package/dist/client/index.d.ts +137 -44
- package/dist/client/index.mjs +726 -26
- package/dist/core/index.d.ts +32 -241
- package/dist/core/index.mjs +294 -568
- package/dist/database-BVY1GqE7.d.ts +95 -0
- package/dist/driver/better-sqlite3.d.ts +8 -0
- package/dist/driver/better-sqlite3.mjs +63 -0
- package/dist/driver/bun.mjs +61 -0
- package/dist/driver/expo.mjs +55 -0
- package/dist/driver/node.d.ts +8 -0
- package/dist/driver/node.mjs +60 -0
- package/dist/driver/wa-sqlite.d.ts +34 -0
- package/dist/driver/wa-sqlite.mjs +141 -0
- package/dist/errors-C00ed08Q.d.ts +101 -0
- package/dist/file-migrations/index.d.ts +16 -0
- package/dist/file-migrations/index.mjs +128 -0
- package/dist/index-CLdNrcPz.d.ts +16 -0
- package/dist/replication/coordinator/etcd.d.ts +44 -0
- package/dist/replication/coordinator/etcd.mjs +650 -0
- package/dist/replication/index.d.ts +491 -0
- package/dist/replication/index.mjs +3784 -0
- package/dist/server/index.d.ts +121 -54
- package/dist/server/index.mjs +347 -114
- package/dist/sirannon-Cd-lK6T0.d.ts +31 -0
- package/dist/transport/grpc.d.ts +316 -0
- package/dist/transport/grpc.mjs +3341 -0
- package/dist/transport/memory.d.ts +221 -0
- package/dist/transport/memory.mjs +337 -0
- package/dist/types-B2byqt0B.d.ts +273 -0
- package/dist/types-BEu1I_9_.d.ts +139 -0
- package/dist/types-BFSsG77t.d.ts +29 -0
- package/dist/types-BeozgNPr.d.ts +26 -0
- package/dist/{types-DArCObcu.d.ts → types-D-74JiXb.d.ts} +80 -1
- package/dist/vfs-INWQ5DTE.mjs +2 -0
- package/package.json +106 -11
- package/dist/chunk-VI4UP4RR.mjs +0 -417
- package/dist/protocol-BX1H-_Mz.d.ts +0 -104
- package/dist/sirannon-BJ8Yd1Uf.d.ts +0 -148
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { compatibilityAllowsPromotion } from '../../chunk-ER7ODTDA.mjs';
|
|
2
|
+
import { CoordinatorError, NoSafePrimaryError } from '../../chunk-UVMVN3OT.mjs';
|
|
3
|
+
import '../../chunk-O7BHI3CF.mjs';
|
|
4
|
+
import { Etcd3 } from 'etcd3';
|
|
5
|
+
|
|
6
|
+
var MIN_AUTOMATIC_FAILOVER_VOTERS = 3;
|
|
7
|
+
var EtcdClusterCoordinator = class {
|
|
8
|
+
client;
|
|
9
|
+
namespace;
|
|
10
|
+
onWatcherError;
|
|
11
|
+
leases = /* @__PURE__ */ new Map();
|
|
12
|
+
watchers = /* @__PURE__ */ new Set();
|
|
13
|
+
constructor(options) {
|
|
14
|
+
assertEtcdOptions(options);
|
|
15
|
+
this.client = new Etcd3(toEtcdOptions(options));
|
|
16
|
+
this.namespace = this.client.namespace(normaliseKeyPrefix(options.keyPrefix));
|
|
17
|
+
this.onWatcherError = options.onWatcherError;
|
|
18
|
+
}
|
|
19
|
+
async tryAcquireControllerLease(input) {
|
|
20
|
+
assertNonEmpty(input.clusterId, "clusterId");
|
|
21
|
+
assertNonEmpty(input.holderId, "holderId");
|
|
22
|
+
assertPositiveTtl(input.ttlMs);
|
|
23
|
+
const key = controllerLeaseKey(input.clusterId);
|
|
24
|
+
const lease = this.namespace.lease(ttlMsToSeconds(input.ttlMs));
|
|
25
|
+
const leaseId = await lease.grant();
|
|
26
|
+
const grantedAtMs = Date.now();
|
|
27
|
+
const value = serializeLease({
|
|
28
|
+
id: leaseId,
|
|
29
|
+
kind: "controller",
|
|
30
|
+
clusterId: input.clusterId,
|
|
31
|
+
holderId: input.holderId,
|
|
32
|
+
ttlMs: input.ttlMs,
|
|
33
|
+
grantedAtMs,
|
|
34
|
+
expiresAtMs: grantedAtMs + input.ttlMs,
|
|
35
|
+
metadata: cloneMetadata(input.metadata)
|
|
36
|
+
});
|
|
37
|
+
const result = await this.namespace.if(key, "Create", "==", 0).then(this.namespace.put(key).value(value).lease(leaseId)).commit();
|
|
38
|
+
if (!result.succeeded) {
|
|
39
|
+
await revokeLeaseQuietly(lease);
|
|
40
|
+
const current = await this.getLeaseFromKey(key);
|
|
41
|
+
return { acquired: false, lease: current };
|
|
42
|
+
}
|
|
43
|
+
this.trackLease(lease, {
|
|
44
|
+
leaseId,
|
|
45
|
+
key,
|
|
46
|
+
ttlMs: input.ttlMs,
|
|
47
|
+
ttlSeconds: ttlMsToSeconds(input.ttlMs),
|
|
48
|
+
kind: "controller",
|
|
49
|
+
clusterId: input.clusterId,
|
|
50
|
+
holderId: input.holderId,
|
|
51
|
+
metadata: cloneMetadata(input.metadata)
|
|
52
|
+
});
|
|
53
|
+
const parsed = parseLease(value);
|
|
54
|
+
return { acquired: true, lease: parsed };
|
|
55
|
+
}
|
|
56
|
+
async renewLease(leaseId, ttlMs) {
|
|
57
|
+
assertNonEmpty(leaseId, "leaseId");
|
|
58
|
+
assertPositiveTtl(ttlMs);
|
|
59
|
+
const entry = this.leases.get(leaseId);
|
|
60
|
+
if (!entry) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await entry.lease.keepaliveOnce();
|
|
65
|
+
const renewedAtMs = Date.now();
|
|
66
|
+
const leaseValue = {
|
|
67
|
+
id: leaseId,
|
|
68
|
+
kind: entry.kind,
|
|
69
|
+
clusterId: entry.clusterId,
|
|
70
|
+
holderId: entry.holderId,
|
|
71
|
+
ttlMs,
|
|
72
|
+
grantedAtMs: renewedAtMs,
|
|
73
|
+
expiresAtMs: renewedAtMs + ttlMs,
|
|
74
|
+
metadata: cloneMetadata(entry.metadata)
|
|
75
|
+
};
|
|
76
|
+
const value = entry.kind === "node-session" && entry.nodeSession ? JSON.stringify({ ...entry.nodeSession, lease: leaseValue }) : serializeLease(leaseValue);
|
|
77
|
+
await this.namespace.put(entry.key).value(value).ignoreLease();
|
|
78
|
+
entry.ttlMs = ttlMs;
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
this.leases.delete(leaseId);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async releaseLease(leaseId) {
|
|
86
|
+
assertNonEmpty(leaseId, "leaseId");
|
|
87
|
+
const entry = this.leases.get(leaseId);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
this.leases.delete(leaseId);
|
|
92
|
+
const currentValue = await this.namespace.get(entry.key).string();
|
|
93
|
+
const currentLeaseId = currentValue ? parseLeaseIdForEntry(entry.kind, currentValue) : null;
|
|
94
|
+
let released = false;
|
|
95
|
+
if (currentLeaseId === leaseId && currentValue) {
|
|
96
|
+
const result = await this.namespace.if(entry.key, "Value", "==", currentValue).then(this.namespace.delete().key(entry.key)).commit();
|
|
97
|
+
released = result.succeeded === true;
|
|
98
|
+
}
|
|
99
|
+
await revokeLeaseQuietly(entry.lease);
|
|
100
|
+
return released;
|
|
101
|
+
}
|
|
102
|
+
async registerNodeSession(input) {
|
|
103
|
+
assertNonEmpty(input.clusterId, "clusterId");
|
|
104
|
+
assertNonEmpty(input.nodeId, "nodeId");
|
|
105
|
+
assertPositiveTtl(input.ttlMs);
|
|
106
|
+
const key = nodeSessionKey(input.clusterId, input.nodeId);
|
|
107
|
+
const existingRawSession = await this.namespace.get(key).string();
|
|
108
|
+
if (existingRawSession) {
|
|
109
|
+
const existingSession = parseNodeSession(existingRawSession);
|
|
110
|
+
if (existingSession.lease.expiresAtMs > Date.now()) {
|
|
111
|
+
throw new CoordinatorError(`Node session '${input.nodeId}' is already registered`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const lease = this.namespace.lease(ttlMsToSeconds(input.ttlMs));
|
|
115
|
+
const leaseId = await lease.grant();
|
|
116
|
+
const grantedAtMs = Date.now();
|
|
117
|
+
const session = {
|
|
118
|
+
clusterId: input.clusterId,
|
|
119
|
+
nodeId: input.nodeId,
|
|
120
|
+
lease: {
|
|
121
|
+
id: leaseId,
|
|
122
|
+
kind: "node-session",
|
|
123
|
+
clusterId: input.clusterId,
|
|
124
|
+
holderId: input.nodeId,
|
|
125
|
+
ttlMs: input.ttlMs,
|
|
126
|
+
grantedAtMs,
|
|
127
|
+
expiresAtMs: grantedAtMs + input.ttlMs,
|
|
128
|
+
metadata: cloneMetadata(input.metadata)
|
|
129
|
+
},
|
|
130
|
+
endpoint: input.endpoint,
|
|
131
|
+
groupIds: [...input.groupIds ?? []],
|
|
132
|
+
dataBearing: input.dataBearing ?? true,
|
|
133
|
+
voting: input.voting ?? true,
|
|
134
|
+
compatibility: cloneCompatibility(input.compatibility),
|
|
135
|
+
metadata: cloneMetadata(input.metadata)
|
|
136
|
+
};
|
|
137
|
+
const rawSession = JSON.stringify(session);
|
|
138
|
+
const transaction = existingRawSession ? this.namespace.if(key, "Value", "==", existingRawSession) : this.namespace.if(key, "Create", "==", 0);
|
|
139
|
+
const result = await transaction.then(this.namespace.put(key).value(rawSession).lease(leaseId)).commit();
|
|
140
|
+
if (!result.succeeded) {
|
|
141
|
+
await revokeLeaseQuietly(lease);
|
|
142
|
+
throw new CoordinatorError(`Node session '${input.nodeId}' registration conflicted with a concurrent write`);
|
|
143
|
+
}
|
|
144
|
+
this.trackLease(lease, {
|
|
145
|
+
leaseId,
|
|
146
|
+
key,
|
|
147
|
+
ttlMs: input.ttlMs,
|
|
148
|
+
ttlSeconds: ttlMsToSeconds(input.ttlMs),
|
|
149
|
+
kind: "node-session",
|
|
150
|
+
clusterId: input.clusterId,
|
|
151
|
+
holderId: input.nodeId,
|
|
152
|
+
metadata: cloneMetadata(input.metadata),
|
|
153
|
+
nodeSession: {
|
|
154
|
+
clusterId: session.clusterId,
|
|
155
|
+
nodeId: session.nodeId,
|
|
156
|
+
endpoint: session.endpoint,
|
|
157
|
+
groupIds: [...session.groupIds],
|
|
158
|
+
dataBearing: session.dataBearing,
|
|
159
|
+
voting: session.voting,
|
|
160
|
+
compatibility: cloneCompatibility(session.compatibility),
|
|
161
|
+
metadata: cloneMetadata(session.metadata)
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
return parseNodeSession(rawSession);
|
|
165
|
+
}
|
|
166
|
+
async getLiveNodeSession(clusterId, nodeId) {
|
|
167
|
+
assertNonEmpty(clusterId, "clusterId");
|
|
168
|
+
assertNonEmpty(nodeId, "nodeId");
|
|
169
|
+
const value = await this.namespace.get(nodeSessionKey(clusterId, nodeId)).string();
|
|
170
|
+
return value ? parseNodeSession(value) : null;
|
|
171
|
+
}
|
|
172
|
+
async deregisterNodeSession(clusterId, nodeId) {
|
|
173
|
+
assertNonEmpty(clusterId, "clusterId");
|
|
174
|
+
assertNonEmpty(nodeId, "nodeId");
|
|
175
|
+
const key = nodeSessionKey(clusterId, nodeId);
|
|
176
|
+
for (const [leaseId, entry] of this.leases) {
|
|
177
|
+
if (entry.key === key) {
|
|
178
|
+
await this.releaseLease(leaseId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async setReplicationGroupState(input) {
|
|
183
|
+
const state = buildReplicationGroupState(input);
|
|
184
|
+
await this.namespace.put(replicationGroupKey(input.clusterId, input.groupId)).value(serializeGroupState(state));
|
|
185
|
+
return cloneReplicationGroupState(state);
|
|
186
|
+
}
|
|
187
|
+
async getReplicationGroupState(clusterId, groupId) {
|
|
188
|
+
assertNonEmpty(clusterId, "clusterId");
|
|
189
|
+
assertNonEmpty(groupId, "groupId");
|
|
190
|
+
const value = await this.namespace.get(replicationGroupKey(clusterId, groupId)).string();
|
|
191
|
+
return value ? parseGroupState(value) : null;
|
|
192
|
+
}
|
|
193
|
+
async watchReplicationGroup(clusterId, groupId, watcher) {
|
|
194
|
+
assertNonEmpty(clusterId, "clusterId");
|
|
195
|
+
assertNonEmpty(groupId, "groupId");
|
|
196
|
+
const key = replicationGroupKey(clusterId, groupId);
|
|
197
|
+
const etcdWatcher = await this.namespace.watch().key(key).create();
|
|
198
|
+
this.watchers.add(etcdWatcher);
|
|
199
|
+
etcdWatcher.on("put", (kv) => {
|
|
200
|
+
try {
|
|
201
|
+
watcher(parseGroupState(kv.value.toString("utf8")));
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const wrappedErr = err instanceof Error ? err : new Error(String(err));
|
|
204
|
+
this.onWatcherError?.(wrappedErr);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
etcdWatcher.on("error", (err) => {
|
|
208
|
+
this.onWatcherError?.(err instanceof Error ? err : new Error(String(err)));
|
|
209
|
+
});
|
|
210
|
+
return async () => {
|
|
211
|
+
this.watchers.delete(etcdWatcher);
|
|
212
|
+
await etcdWatcher.cancel();
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async compareAndAdvancePrimaryTerm(input) {
|
|
216
|
+
assertNonEmpty(input.clusterId, "clusterId");
|
|
217
|
+
assertNonEmpty(input.groupId, "groupId");
|
|
218
|
+
assertNonNegativeTerm(input.expectedPrimaryTerm);
|
|
219
|
+
assertNonEmpty(input.nextPrimary.nodeId, "nextPrimary.nodeId");
|
|
220
|
+
const key = replicationGroupKey(input.clusterId, input.groupId);
|
|
221
|
+
const currentRaw = await this.namespace.get(key).string();
|
|
222
|
+
if (!currentRaw) {
|
|
223
|
+
return { advanced: false, state: null };
|
|
224
|
+
}
|
|
225
|
+
const current = parseGroupState(currentRaw);
|
|
226
|
+
if (current.primaryTerm !== input.expectedPrimaryTerm) {
|
|
227
|
+
return { advanced: false, state: current };
|
|
228
|
+
}
|
|
229
|
+
assertPrimaryInGroup(input.nextPrimary, current.votingDataBearingNodeIds);
|
|
230
|
+
const next = {
|
|
231
|
+
...current,
|
|
232
|
+
currentPrimary: { ...input.nextPrimary },
|
|
233
|
+
primaryTerm: current.primaryTerm + 1n,
|
|
234
|
+
updatedAtMs: Date.now()
|
|
235
|
+
};
|
|
236
|
+
markDisplacedPrimaryForRepair(next, current.currentPrimary?.nodeId, input.nextPrimary.nodeId);
|
|
237
|
+
const nextRaw = serializeGroupState(next);
|
|
238
|
+
const result = await this.namespace.if(key, "Value", "==", currentRaw).then(this.namespace.put(key).value(nextRaw)).commit();
|
|
239
|
+
if (result.succeeded) {
|
|
240
|
+
return { advanced: true, state: cloneReplicationGroupState(next) };
|
|
241
|
+
}
|
|
242
|
+
return { advanced: false, state: await this.getReplicationGroupState(input.clusterId, input.groupId) };
|
|
243
|
+
}
|
|
244
|
+
async updateInSyncSet(input) {
|
|
245
|
+
assertNonEmpty(input.clusterId, "clusterId");
|
|
246
|
+
assertNonEmpty(input.groupId, "groupId");
|
|
247
|
+
if (input.durabilityPointSeq !== void 0) {
|
|
248
|
+
assertNonNegativeSeq(input.durabilityPointSeq, "durabilityPointSeq");
|
|
249
|
+
}
|
|
250
|
+
const inSyncNodeIds = normaliseNodeIds(input.inSyncNodeIds, "inSyncNodeIds");
|
|
251
|
+
return this.updateGroupStateWithRetry(input.clusterId, input.groupId, (state) => {
|
|
252
|
+
assertSubset(inSyncNodeIds, state.votingDataBearingNodeIds, "inSyncNodeIds");
|
|
253
|
+
assertNoInSyncAdditions(state.inSyncNodeIds, inSyncNodeIds);
|
|
254
|
+
const durabilityPointSeq = input.durabilityPointSeq !== void 0 && input.durabilityPointSeq > state.durabilityPointSeq ? input.durabilityPointSeq : state.durabilityPointSeq;
|
|
255
|
+
if (arraysEqual(inSyncNodeIds, state.inSyncNodeIds) && durabilityPointSeq === state.durabilityPointSeq) {
|
|
256
|
+
return state;
|
|
257
|
+
}
|
|
258
|
+
return { ...state, durabilityPointSeq, inSyncNodeIds, updatedAtMs: Date.now() };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async admitNodeToInSyncSet(input) {
|
|
262
|
+
assertNonEmpty(input.clusterId, "clusterId");
|
|
263
|
+
assertNonEmpty(input.groupId, "groupId");
|
|
264
|
+
assertNonEmpty(input.nodeId, "nodeId");
|
|
265
|
+
assertNonEmpty(input.sourceNodeId, "sourceNodeId");
|
|
266
|
+
assertNonNegativeSeq(input.appliedSeq, "appliedSeq");
|
|
267
|
+
return this.updateGroupStateWithRetry(input.clusterId, input.groupId, (state) => {
|
|
268
|
+
if (!state.votingDataBearingNodeIds.includes(input.nodeId)) {
|
|
269
|
+
throw new RangeError(`Node '${input.nodeId}' is not configured for the replication group`);
|
|
270
|
+
}
|
|
271
|
+
if (state.currentPrimary?.nodeId !== input.sourceNodeId || state.drainingNodeIds.includes(input.nodeId) || state.faultedNodeIds.includes(input.nodeId) || input.appliedSeq < state.durabilityPointSeq) {
|
|
272
|
+
return state;
|
|
273
|
+
}
|
|
274
|
+
const inSyncNodeIds = state.inSyncNodeIds.includes(input.nodeId) ? state.inSyncNodeIds : [...state.inSyncNodeIds, input.nodeId];
|
|
275
|
+
const repairingNodeIds = removeNodeId(state.repairingNodeIds, input.nodeId);
|
|
276
|
+
if (arraysEqual(inSyncNodeIds, state.inSyncNodeIds) && arraysEqual(repairingNodeIds, state.repairingNodeIds)) {
|
|
277
|
+
return state;
|
|
278
|
+
}
|
|
279
|
+
return { ...state, inSyncNodeIds, repairingNodeIds, updatedAtMs: Date.now() };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
async updateNodeMaintenance(input) {
|
|
283
|
+
assertNonEmpty(input.clusterId, "clusterId");
|
|
284
|
+
assertNonEmpty(input.groupId, "groupId");
|
|
285
|
+
assertNonEmpty(input.nodeId, "nodeId");
|
|
286
|
+
return this.updateGroupStateWithRetry(input.clusterId, input.groupId, (state) => {
|
|
287
|
+
if (!state.votingDataBearingNodeIds.includes(input.nodeId)) {
|
|
288
|
+
throw new RangeError(`Node '${input.nodeId}' is not configured for the replication group`);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
...state,
|
|
292
|
+
drainingNodeIds: setMembership(state.drainingNodeIds, input.nodeId, input.draining),
|
|
293
|
+
repairingNodeIds: setMembership(state.repairingNodeIds, input.nodeId, input.repairing),
|
|
294
|
+
faultedNodeIds: setMembership(state.faultedNodeIds, input.nodeId, input.faulted),
|
|
295
|
+
inSyncNodeIds: input.draining === true || input.repairing === true || input.faulted === true ? removeNodeId(state.inSyncNodeIds, input.nodeId) : state.inSyncNodeIds,
|
|
296
|
+
updatedAtMs: Date.now()
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async promoteEligibleReplica(input) {
|
|
301
|
+
assertNonEmpty(input.clusterId, "clusterId");
|
|
302
|
+
assertNonEmpty(input.groupId, "groupId");
|
|
303
|
+
const excludedNodeIds = new Set(input.excludeNodeIds ?? []);
|
|
304
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
305
|
+
const state = await this.getReplicationGroupState(input.clusterId, input.groupId);
|
|
306
|
+
if (!state) {
|
|
307
|
+
throw new NoSafePrimaryError(`No replication group '${input.groupId}' is registered`);
|
|
308
|
+
}
|
|
309
|
+
if (state.votingDataBearingNodeIds.length < MIN_AUTOMATIC_FAILOVER_VOTERS) {
|
|
310
|
+
throw new NoSafePrimaryError(
|
|
311
|
+
`Automatic promotion requires at least ${MIN_AUTOMATIC_FAILOVER_VOTERS} voting data-bearing nodes`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
for (const nodeId of state.votingDataBearingNodeIds) {
|
|
315
|
+
if (nodeId === state.currentPrimary?.nodeId || excludedNodeIds.has(nodeId)) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const session = await this.getLiveNodeSession(input.clusterId, nodeId);
|
|
319
|
+
if (!isEligiblePromotionSession(state, nodeId, session)) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const advanced = await this.compareAndAdvancePrimaryTerm({
|
|
323
|
+
clusterId: input.clusterId,
|
|
324
|
+
groupId: input.groupId,
|
|
325
|
+
expectedPrimaryTerm: state.primaryTerm,
|
|
326
|
+
nextPrimary: session.endpoint ? { nodeId, endpoint: session.endpoint } : { nodeId }
|
|
327
|
+
});
|
|
328
|
+
if (advanced.advanced && advanced.state) {
|
|
329
|
+
return advanced.state;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
throw new NoSafePrimaryError(`No safe primary is available for replication group '${input.groupId}'`);
|
|
334
|
+
}
|
|
335
|
+
async close() {
|
|
336
|
+
const watcherCancels = [];
|
|
337
|
+
for (const watcher of this.watchers) {
|
|
338
|
+
watcherCancels.push(watcher.cancel());
|
|
339
|
+
}
|
|
340
|
+
this.watchers.clear();
|
|
341
|
+
await Promise.allSettled(watcherCancels);
|
|
342
|
+
const leaseRevokes = [];
|
|
343
|
+
for (const entry of this.leases.values()) {
|
|
344
|
+
leaseRevokes.push(revokeLeaseQuietly(entry.lease));
|
|
345
|
+
}
|
|
346
|
+
this.leases.clear();
|
|
347
|
+
await Promise.allSettled(leaseRevokes);
|
|
348
|
+
this.client.close();
|
|
349
|
+
}
|
|
350
|
+
async getLeaseFromKey(key) {
|
|
351
|
+
const value = await this.namespace.get(key).string();
|
|
352
|
+
return value ? parseLease(value) : null;
|
|
353
|
+
}
|
|
354
|
+
trackLease(lease, entry) {
|
|
355
|
+
const fullEntry = { ...entry, lease };
|
|
356
|
+
this.leases.set(entry.leaseId, fullEntry);
|
|
357
|
+
lease.on("lost", (err) => {
|
|
358
|
+
this.leases.delete(entry.leaseId);
|
|
359
|
+
this.onWatcherError?.(err instanceof Error ? err : new CoordinatorError(String(err)));
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
async updateGroupStateWithRetry(clusterId, groupId, mutate) {
|
|
363
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
364
|
+
const state = await this.getReplicationGroupState(clusterId, groupId);
|
|
365
|
+
if (!state) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const next = mutate(state);
|
|
369
|
+
if (next === state) {
|
|
370
|
+
return cloneReplicationGroupState(state);
|
|
371
|
+
}
|
|
372
|
+
const result = await this.casGroupState(state, next);
|
|
373
|
+
if (result.updated) {
|
|
374
|
+
return result.state;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
throw new CoordinatorError(`Failed to update replication group '${groupId}' after concurrent coordinator writes`);
|
|
378
|
+
}
|
|
379
|
+
async casGroupState(previous, next) {
|
|
380
|
+
const key = replicationGroupKey(previous.clusterId, previous.groupId);
|
|
381
|
+
const previousRaw = serializeGroupState(previous);
|
|
382
|
+
const nextRaw = serializeGroupState(next);
|
|
383
|
+
const result = await this.namespace.if(key, "Value", "==", previousRaw).then(this.namespace.put(key).value(nextRaw)).commit();
|
|
384
|
+
if (result.succeeded) {
|
|
385
|
+
return { updated: true, state: cloneReplicationGroupState(next) };
|
|
386
|
+
}
|
|
387
|
+
return { updated: false, state: await this.getReplicationGroupState(previous.clusterId, previous.groupId) };
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
function createEtcdCoordinator(options) {
|
|
391
|
+
return new EtcdClusterCoordinator(options);
|
|
392
|
+
}
|
|
393
|
+
function assertEtcdOptions(options) {
|
|
394
|
+
const hosts = Array.isArray(options.hosts) ? options.hosts : [options.hosts];
|
|
395
|
+
if (hosts.length === 0) {
|
|
396
|
+
throw new TypeError("hosts must contain at least one etcd endpoint");
|
|
397
|
+
}
|
|
398
|
+
for (const host of hosts) {
|
|
399
|
+
assertNonEmpty(host, "hosts entry");
|
|
400
|
+
if (!options.allowInsecure && !host.startsWith("https://")) {
|
|
401
|
+
throw new TypeError("production coordinator access requires https etcd endpoints");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
assertNonEmpty(options.keyPrefix, "keyPrefix");
|
|
405
|
+
if (!options.allowInsecure && !options.credentials) {
|
|
406
|
+
throw new TypeError("production coordinator access requires TLS credentials");
|
|
407
|
+
}
|
|
408
|
+
const hasMtlsIdentity = Boolean(options.credentials?.privateKey && options.credentials.certChain);
|
|
409
|
+
const hasPasswordAuth = Boolean(options.auth?.username && options.auth.password);
|
|
410
|
+
if (!options.allowInsecure && !hasMtlsIdentity && !hasPasswordAuth) {
|
|
411
|
+
throw new TypeError("production coordinator access requires an authenticated Sirannon identity");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function toEtcdOptions(options) {
|
|
415
|
+
const defaultCallTimeoutMs = options.defaultCallTimeoutMs;
|
|
416
|
+
const defaultCallOptions = defaultCallTimeoutMs ? () => ({ deadline: Date.now() + defaultCallTimeoutMs }) : void 0;
|
|
417
|
+
return {
|
|
418
|
+
hosts: options.hosts,
|
|
419
|
+
credentials: options.credentials,
|
|
420
|
+
auth: options.auth,
|
|
421
|
+
grpcOptions: options.grpcOptions,
|
|
422
|
+
dialTimeout: options.dialTimeoutMs,
|
|
423
|
+
defaultCallOptions
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function normaliseKeyPrefix(prefix) {
|
|
427
|
+
const trimmed = prefix.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
428
|
+
if (trimmed.length === 0) {
|
|
429
|
+
throw new TypeError("keyPrefix must not resolve to the etcd root");
|
|
430
|
+
}
|
|
431
|
+
return `${trimmed}/`;
|
|
432
|
+
}
|
|
433
|
+
function controllerLeaseKey(clusterId) {
|
|
434
|
+
return `clusters/${encodeKey(clusterId)}/controller`;
|
|
435
|
+
}
|
|
436
|
+
function nodeSessionKey(clusterId, nodeId) {
|
|
437
|
+
return `clusters/${encodeKey(clusterId)}/nodes/${encodeKey(nodeId)}`;
|
|
438
|
+
}
|
|
439
|
+
function replicationGroupKey(clusterId, groupId) {
|
|
440
|
+
return `clusters/${encodeKey(clusterId)}/groups/${encodeKey(groupId)}`;
|
|
441
|
+
}
|
|
442
|
+
function encodeKey(value) {
|
|
443
|
+
return encodeURIComponent(value);
|
|
444
|
+
}
|
|
445
|
+
function ttlMsToSeconds(ttlMs) {
|
|
446
|
+
return Math.max(1, Math.ceil(ttlMs / 1e3));
|
|
447
|
+
}
|
|
448
|
+
function buildReplicationGroupState(input) {
|
|
449
|
+
assertNonEmpty(input.clusterId, "clusterId");
|
|
450
|
+
assertNonEmpty(input.groupId, "groupId");
|
|
451
|
+
const votingDataBearingNodeIds = normaliseNodeIds(input.votingDataBearingNodeIds, "votingDataBearingNodeIds");
|
|
452
|
+
const inSyncNodeIds = normaliseNodeIds(input.inSyncNodeIds ?? [], "inSyncNodeIds");
|
|
453
|
+
const drainingNodeIds = normaliseNodeIds(input.drainingNodeIds ?? [], "drainingNodeIds");
|
|
454
|
+
const repairingNodeIds = normaliseNodeIds(input.repairingNodeIds ?? [], "repairingNodeIds");
|
|
455
|
+
const faultedNodeIds = normaliseNodeIds(input.faultedNodeIds ?? [], "faultedNodeIds");
|
|
456
|
+
assertNonNegativeTerm(input.primaryTerm ?? 0n);
|
|
457
|
+
assertNonNegativeSeq(input.durabilityPointSeq ?? 0n, "durabilityPointSeq");
|
|
458
|
+
assertPrimaryInGroup(input.currentPrimary ?? null, votingDataBearingNodeIds);
|
|
459
|
+
assertSubset(inSyncNodeIds, votingDataBearingNodeIds, "inSyncNodeIds");
|
|
460
|
+
assertSubset(drainingNodeIds, votingDataBearingNodeIds, "drainingNodeIds");
|
|
461
|
+
assertSubset(repairingNodeIds, votingDataBearingNodeIds, "repairingNodeIds");
|
|
462
|
+
assertSubset(faultedNodeIds, votingDataBearingNodeIds, "faultedNodeIds");
|
|
463
|
+
return {
|
|
464
|
+
clusterId: input.clusterId,
|
|
465
|
+
groupId: input.groupId,
|
|
466
|
+
votingDataBearingNodeIds,
|
|
467
|
+
currentPrimary: input.currentPrimary ? { ...input.currentPrimary } : null,
|
|
468
|
+
primaryTerm: input.primaryTerm ?? 0n,
|
|
469
|
+
durabilityPointSeq: input.durabilityPointSeq ?? 0n,
|
|
470
|
+
inSyncNodeIds,
|
|
471
|
+
drainingNodeIds,
|
|
472
|
+
repairingNodeIds,
|
|
473
|
+
faultedNodeIds,
|
|
474
|
+
compatibility: cloneCompatibility(input.compatibility),
|
|
475
|
+
updatedAtMs: Date.now()
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function serializeLease(lease) {
|
|
479
|
+
return JSON.stringify(lease);
|
|
480
|
+
}
|
|
481
|
+
function parseLease(raw) {
|
|
482
|
+
const value = JSON.parse(raw);
|
|
483
|
+
return {
|
|
484
|
+
...value,
|
|
485
|
+
metadata: cloneMetadata(value.metadata)
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function parseNodeSession(raw) {
|
|
489
|
+
const value = JSON.parse(raw);
|
|
490
|
+
return {
|
|
491
|
+
...value,
|
|
492
|
+
lease: parseLease(JSON.stringify(value.lease)),
|
|
493
|
+
groupIds: [...value.groupIds],
|
|
494
|
+
compatibility: cloneCompatibility(value.compatibility),
|
|
495
|
+
metadata: cloneMetadata(value.metadata)
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function parseLeaseIdForEntry(kind, raw) {
|
|
499
|
+
try {
|
|
500
|
+
if (kind === "node-session") {
|
|
501
|
+
return parseNodeSession(raw).lease.id;
|
|
502
|
+
}
|
|
503
|
+
return parseLease(raw).id;
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function serializeGroupState(state) {
|
|
509
|
+
const serialized = {
|
|
510
|
+
...state,
|
|
511
|
+
currentPrimary: state.currentPrimary ? { ...state.currentPrimary } : null,
|
|
512
|
+
votingDataBearingNodeIds: [...state.votingDataBearingNodeIds],
|
|
513
|
+
primaryTerm: state.primaryTerm.toString(),
|
|
514
|
+
durabilityPointSeq: state.durabilityPointSeq.toString(),
|
|
515
|
+
inSyncNodeIds: [...state.inSyncNodeIds],
|
|
516
|
+
drainingNodeIds: [...state.drainingNodeIds],
|
|
517
|
+
repairingNodeIds: [...state.repairingNodeIds],
|
|
518
|
+
faultedNodeIds: [...state.faultedNodeIds],
|
|
519
|
+
compatibility: cloneCompatibility(state.compatibility)
|
|
520
|
+
};
|
|
521
|
+
return JSON.stringify(serialized);
|
|
522
|
+
}
|
|
523
|
+
function parseGroupState(raw) {
|
|
524
|
+
const value = JSON.parse(raw);
|
|
525
|
+
return {
|
|
526
|
+
...value,
|
|
527
|
+
currentPrimary: value.currentPrimary ? { ...value.currentPrimary } : null,
|
|
528
|
+
votingDataBearingNodeIds: [...value.votingDataBearingNodeIds],
|
|
529
|
+
primaryTerm: BigInt(value.primaryTerm),
|
|
530
|
+
durabilityPointSeq: value.durabilityPointSeq !== void 0 ? BigInt(value.durabilityPointSeq) : 0n,
|
|
531
|
+
inSyncNodeIds: [...value.inSyncNodeIds],
|
|
532
|
+
drainingNodeIds: [...value.drainingNodeIds],
|
|
533
|
+
repairingNodeIds: [...value.repairingNodeIds],
|
|
534
|
+
faultedNodeIds: [...value.faultedNodeIds ?? []],
|
|
535
|
+
compatibility: cloneCompatibility(value.compatibility)
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
function cloneReplicationGroupState(state) {
|
|
539
|
+
return {
|
|
540
|
+
...state,
|
|
541
|
+
currentPrimary: state.currentPrimary ? { ...state.currentPrimary } : null,
|
|
542
|
+
votingDataBearingNodeIds: [...state.votingDataBearingNodeIds],
|
|
543
|
+
durabilityPointSeq: state.durabilityPointSeq,
|
|
544
|
+
inSyncNodeIds: [...state.inSyncNodeIds],
|
|
545
|
+
drainingNodeIds: [...state.drainingNodeIds],
|
|
546
|
+
repairingNodeIds: [...state.repairingNodeIds],
|
|
547
|
+
faultedNodeIds: [...state.faultedNodeIds],
|
|
548
|
+
compatibility: cloneCompatibility(state.compatibility)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function isEligiblePromotionSession(state, nodeId, session) {
|
|
552
|
+
if (!session) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
return session.dataBearing && session.voting && state.inSyncNodeIds.includes(nodeId) && compatibilityAllowsPromotion(state.compatibility, session.compatibility) && !state.drainingNodeIds.includes(nodeId) && !state.repairingNodeIds.includes(nodeId) && !state.faultedNodeIds.includes(nodeId);
|
|
556
|
+
}
|
|
557
|
+
function normaliseNodeIds(nodeIds, name) {
|
|
558
|
+
const seen = /* @__PURE__ */ new Set();
|
|
559
|
+
const result = [];
|
|
560
|
+
for (const nodeId of nodeIds) {
|
|
561
|
+
assertNonEmpty(nodeId, `${name} entry`);
|
|
562
|
+
if (seen.has(nodeId)) {
|
|
563
|
+
throw new RangeError(`${name} contains duplicate node id '${nodeId}'`);
|
|
564
|
+
}
|
|
565
|
+
seen.add(nodeId);
|
|
566
|
+
result.push(nodeId);
|
|
567
|
+
}
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
570
|
+
function setMembership(values, nodeId, enabled) {
|
|
571
|
+
if (enabled === void 0) return values;
|
|
572
|
+
const next = values.filter((value) => value !== nodeId);
|
|
573
|
+
if (enabled) {
|
|
574
|
+
next.push(nodeId);
|
|
575
|
+
}
|
|
576
|
+
return next;
|
|
577
|
+
}
|
|
578
|
+
function markDisplacedPrimaryForRepair(state, displacedPrimaryId, nextPrimaryId) {
|
|
579
|
+
if (!displacedPrimaryId || displacedPrimaryId === nextPrimaryId) return;
|
|
580
|
+
state.inSyncNodeIds = removeNodeId(state.inSyncNodeIds, displacedPrimaryId);
|
|
581
|
+
state.repairingNodeIds = setMembership(state.repairingNodeIds, displacedPrimaryId, true);
|
|
582
|
+
}
|
|
583
|
+
function removeNodeId(values, nodeId) {
|
|
584
|
+
return values.filter((value) => value !== nodeId);
|
|
585
|
+
}
|
|
586
|
+
function assertSubset(values, allowed, name) {
|
|
587
|
+
const allowedSet = new Set(allowed);
|
|
588
|
+
for (const value of values) {
|
|
589
|
+
if (!allowedSet.has(value)) {
|
|
590
|
+
throw new RangeError(`${name} contains node id '${value}' that is not configured for the replication group`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function assertPrimaryInGroup(primary, votingDataBearingNodeIds) {
|
|
595
|
+
if (!primary) return;
|
|
596
|
+
assertNonEmpty(primary.nodeId, "primary.nodeId");
|
|
597
|
+
if (!votingDataBearingNodeIds.includes(primary.nodeId)) {
|
|
598
|
+
throw new RangeError(`Primary node '${primary.nodeId}' is not configured for the replication group`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function assertNonNegativeTerm(term) {
|
|
602
|
+
if (term < 0n) {
|
|
603
|
+
throw new RangeError("primaryTerm must not be negative");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function assertNonNegativeSeq(seq, name) {
|
|
607
|
+
if (seq < 0n) {
|
|
608
|
+
throw new RangeError(`${name} must not be negative`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function assertNoInSyncAdditions(previous, next) {
|
|
612
|
+
const previousSet = new Set(previous);
|
|
613
|
+
for (const nodeId of next) {
|
|
614
|
+
if (!previousSet.has(nodeId)) {
|
|
615
|
+
throw new RangeError(`Node '${nodeId}' cannot be added to the in-sync set without catch-up proof`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function arraysEqual(left, right) {
|
|
620
|
+
if (left.length !== right.length) return false;
|
|
621
|
+
for (let i = 0; i < left.length; i++) {
|
|
622
|
+
if (left[i] !== right[i]) return false;
|
|
623
|
+
}
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
function cloneCompatibility(compatibility) {
|
|
627
|
+
return compatibility ? { ...compatibility } : void 0;
|
|
628
|
+
}
|
|
629
|
+
function cloneMetadata(metadata) {
|
|
630
|
+
return metadata ? { ...metadata } : void 0;
|
|
631
|
+
}
|
|
632
|
+
function assertPositiveTtl(ttlMs) {
|
|
633
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
634
|
+
throw new RangeError("ttlMs must be a positive safe integer");
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function assertNonEmpty(value, name) {
|
|
638
|
+
if (value.length === 0) {
|
|
639
|
+
throw new TypeError(`${name} must not be empty`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
async function revokeLeaseQuietly(lease) {
|
|
643
|
+
try {
|
|
644
|
+
await lease.revoke();
|
|
645
|
+
} catch {
|
|
646
|
+
lease.release();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export { EtcdClusterCoordinator, createEtcdCoordinator };
|