dsh-lan-peers 0.7.3 → 0.7.4
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/host/server.mjs +13 -5
- package/host/test/group-commit.test.mjs +48 -1
- package/package.json +1 -1
package/host/server.mjs
CHANGED
|
@@ -96,7 +96,7 @@ import { parseArgs } from 'node:util';
|
|
|
96
96
|
// ---------------------------------------------------------------------------
|
|
97
97
|
|
|
98
98
|
/** 插件版本,用于 /v1/health.version */
|
|
99
|
-
export const PLUGIN_VERSION = '0.7.
|
|
99
|
+
export const PLUGIN_VERSION = '0.7.4';
|
|
100
100
|
|
|
101
101
|
/** §4.1 允许的时钟偏移 */
|
|
102
102
|
export const CLOCK_SKEW_MS = 60_000;
|
|
@@ -1095,10 +1095,16 @@ export class GroupStore {
|
|
|
1095
1095
|
return name;
|
|
1096
1096
|
}
|
|
1097
1097
|
|
|
1098
|
-
/**
|
|
1098
|
+
/**
|
|
1099
|
+
* 追加一条消息(**单阶段写**:quorum:none、以及管理接口/面板的直写路径)。
|
|
1100
|
+
* 取号必须避开 pending 已预留的空号:两阶段提交的提案先占住 lastSeq+1,
|
|
1101
|
+
* 此时若再来一条直写也取 lastSeq+1,两条消息就会共用同一个 seq ——
|
|
1102
|
+
* 客户端按 seq 去重,后一条会被静默丢弃。
|
|
1103
|
+
*/
|
|
1099
1104
|
append({ peerId, name, kind, text = '', meta = {} }) {
|
|
1100
1105
|
const data = this.#load();
|
|
1101
|
-
const
|
|
1106
|
+
const reserved = data.pending.reduce((max, entry) => Math.max(max, Number(entry?.seq) || 0), 0);
|
|
1107
|
+
const seq = Math.max(Number(data.lastSeq) || 0, reserved) + 1;
|
|
1102
1108
|
data.lastSeq = seq;
|
|
1103
1109
|
const message = {
|
|
1104
1110
|
seq,
|
|
@@ -3086,11 +3092,13 @@ export function createPeerServer(overrides = {}) {
|
|
|
3086
3092
|
const id = String(peerId ?? '');
|
|
3087
3093
|
if (id === '') return '';
|
|
3088
3094
|
if (id === store.instanceId) return localAdvertiseBaseUrl();
|
|
3089
|
-
|
|
3090
|
-
|
|
3095
|
+
// 群成员表优先:它同样是成员自报的地址(hub 写 group.json / 成员写 group-key.json),
|
|
3096
|
+
// 但它是**持久化且运维可见可覆盖**的那一份;学到的缓存只做兜底(两者来源相同,顺序不影响线上)。
|
|
3091
3097
|
const fromGroup = String(localGroupContext()?.members?.[id]?.baseUrl ?? '')
|
|
3092
3098
|
.trim().replace(/\/+$/, '');
|
|
3093
3099
|
if (fromGroup !== '') return fromGroup;
|
|
3100
|
+
const learned = String(peerBaseUrls.get(id) ?? '').trim().replace(/\/+$/, '');
|
|
3101
|
+
if (learned !== '') return learned;
|
|
3094
3102
|
const recorded = String(groupStore ? groupStore.state().members[id]?.baseUrl ?? '' : '')
|
|
3095
3103
|
.trim().replace(/\/+$/, '');
|
|
3096
3104
|
if (recorded !== '') return recorded;
|
|
@@ -49,6 +49,8 @@ test('S3 majority:propose→commit 全链路,三台都可见且 commitSeq
|
|
|
49
49
|
assert.equal(a.server.group.state().pendingCount, 0, '提交后 pending 必须清空');
|
|
50
50
|
|
|
51
51
|
// 日志:一行 post、一行 commit、至少一次心跳;不含正文/密钥
|
|
52
|
+
// 心跳按 heartbeatMs(默认 120ms)广播,用例本身只要 ~200ms —— 必须**等事件**,不能靠时序。
|
|
53
|
+
await waitFor(() => lines.some((line) => line.startsWith('[group] heartbeat ')), { timeoutMs: 3_000 });
|
|
52
54
|
const logText = lines.join('\n');
|
|
53
55
|
assert.match(logText, /\[group\] post kind=chat seq=1 /);
|
|
54
56
|
assert.match(logText, /\[group\] commit seq=1 /);
|
|
@@ -203,7 +205,8 @@ test('S3 seq 连续无重复:失败的提案不消耗号,恢复后重试拿
|
|
|
203
205
|
assert.equal(first.status, 201);
|
|
204
206
|
assert.equal(first.json.seq, 1);
|
|
205
207
|
|
|
206
|
-
// 把两台成员的地址改成打不通:只拿得到自己一票 → 503
|
|
208
|
+
// 把两台成员的地址改成打不通:只拿得到自己一票 → 503。
|
|
209
|
+
// 群成员表里的 baseUrl 是权威寻址(baseUrlOf 优先读它),所以这里改完立刻生效。
|
|
207
210
|
const members = a.server.group.state().members;
|
|
208
211
|
members[b.peerId].baseUrl = 'http://127.0.0.1:1';
|
|
209
212
|
members[c.peerId].baseUrl = 'http://127.0.0.1:1';
|
|
@@ -264,6 +267,50 @@ test('S3 成员当 leader 且要求加密时:post 走 X-Peer-Enc 也能两阶
|
|
|
264
267
|
// hub 侧靠 commit 转正(加密通道同理)
|
|
265
268
|
await waitFor(() => a.server.group.history({ sinceSeq: 0 }).length === 1);
|
|
266
269
|
});
|
|
270
|
+
test('S3 取号闸门:pending 占着的号不会被单阶段写(面板/管理接口)复用', async (t) => {
|
|
271
|
+
const cluster = await startCluster(t, { count: 3, commitTimeoutMs: 2_000 });
|
|
272
|
+
const [a, b, c] = cluster.nodes;
|
|
273
|
+
// 黑洞:提案会一直挂在 pending 里,直到 commitTimeoutMs 才丢弃
|
|
274
|
+
const sockets = new Set();
|
|
275
|
+
const blackhole = net.createServer((socket) => {
|
|
276
|
+
sockets.add(socket);
|
|
277
|
+
socket.on('close', () => sockets.delete(socket));
|
|
278
|
+
socket.on('error', () => sockets.delete(socket));
|
|
279
|
+
});
|
|
280
|
+
await new Promise((resolve) => blackhole.listen(0, '127.0.0.1', resolve));
|
|
281
|
+
t.after(() => {
|
|
282
|
+
for (const socket of sockets) socket.destroy();
|
|
283
|
+
blackhole.close();
|
|
284
|
+
});
|
|
285
|
+
blackhole.unref();
|
|
286
|
+
const holeUrl = `http://127.0.0.1:${blackhole.address().port}`;
|
|
287
|
+
const members = a.server.group.state().members;
|
|
288
|
+
members[b.peerId].baseUrl = holeUrl;
|
|
289
|
+
members[c.peerId].baseUrl = holeUrl;
|
|
290
|
+
|
|
291
|
+
const inflight = postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'slow-proposal' });
|
|
292
|
+
await waitFor(() => a.server.group.state().pendingCount === 1, { timeoutMs: 1_500 });
|
|
293
|
+
const pendingSeq = a.server.group.state().lastSeq + 1;
|
|
294
|
+
assert.equal(a.server.group.state().lastSeq, 0, '提案未提交,lastSeq 不推进');
|
|
295
|
+
|
|
296
|
+
// 面板/管理接口走单阶段写(server.group.post → GroupStore.append):必须避开 pending 占的号
|
|
297
|
+
const panel = a.server.group.post({ peerId: 'peer-panel', name: 'panel', kind: 'chat', text: 'panel-msg' });
|
|
298
|
+
assert.equal(panel.seq, pendingSeq + 1, '单阶段写不得复用 pending 占着的 seq');
|
|
299
|
+
|
|
300
|
+
const failed = await inflight;
|
|
301
|
+
assert.equal(failed.status, 503, failed.text);
|
|
302
|
+
assert.equal(a.server.group.state().pendingCount, 0, '超时后 pending 必须清空');
|
|
303
|
+
|
|
304
|
+
// 恢复可达后继续写:号必须继续往前走,且任何两条消息不共用 seq
|
|
305
|
+
members[b.peerId].baseUrl = b.baseUrl;
|
|
306
|
+
members[c.peerId].baseUrl = c.baseUrl;
|
|
307
|
+
const next = await postAs(a.baseUrl, b.peerId, { kind: 'chat', text: 'after-drop' });
|
|
308
|
+
assert.equal(next.status, 201, next.text);
|
|
309
|
+
assert.equal(next.json.seq, panel.seq + 1);
|
|
310
|
+
const seqs = a.server.group.history({ sinceSeq: 0 }).map((m) => m.seq);
|
|
311
|
+
assert.deepEqual(seqs, [panel.seq, panel.seq + 1]);
|
|
312
|
+
assert.equal(new Set(seqs).size, seqs.length, 'seq 不得重复(客户端按 seq 去重)');
|
|
313
|
+
});
|
|
267
314
|
test('S3 提交串行化:连续多条 post 的 seq 严格连续(并发下也不撞号)', async (t) => {
|
|
268
315
|
const cluster = await startCluster(t, { count: 3 });
|
|
269
316
|
const [a, b] = cluster.nodes;
|