feat(overlay): 内容块级寻址 + 实例逐步拉起 + 骨干选路 + 组密钥加密(序24–㉛ 累积同步)
代码
- 内容分发块级寻址:新增 src/net/relay/content/{chunker,store,runtime,source,peer,crypto}.ts
- 组密钥(C 档)确定性加密:AES-256-GCM,块 id β′ = sha256(密文) 前 32 hex;双 epoch 过渡窗口
- 实例生命周期:三处 teardown() 不再杀实例(local/remote/leased-spawner);启动认领 + TCP 探活判孤儿
- 骨干选路:jitter 选路 + endpoint-target;relay client/server/wire/identity/directory/rendezvous/switcher 调整
- 工作台 src/web/server.ts、src/worker/relay-tunnel.ts 装配与候选链观测
脚本与测试
- scripts/overlay-{probe,keyring,jitter}.cjs 更新
- 探针新增 OBS-21(每连接候选数)/ OBS-22(teardown 静态守卫 + 认领面)/ OBS-23(组密钥加密)
- 新增 test/{orchestrator-teardown,orchestrator-rehydrate,overlay-content,overlay-jitter}.test.mjs;relay 两例更新
文档
- 新增交接单:覆盖网络-序24-内容分发块级寻址 / 序25-实例逐步拉起 / 序26-骨干稳定选路与加密
- INDEX.md、交接单/README.md、skills/dsh-auto-handoff-chain/SKILL.md 同步
验收(零回归,2026-09-18 08:0x 复核)
- npm test 201 tests / 200 pass / 0 fail / 1 skipped
- overlay-failover-drill --scene all --table 12 PASS / 0 SKIP / 0 FAIL
- overlay-probe --table 23 PASS / 0 SKIP / 0 FAIL (rc=0)
This commit is contained in:
1 parent
04776af4b1
commit
09ce76f3af
38 files changed
+9133
-211
No files matched your search
+160
-3
@@ -36,6 +36,18 @@
|
||||
* - **D6 开关**:`RELAY_FAILOVER_EXEMPT`(默认 `1`;置 `0` ⇒ 逐字回到序⑦ 行为)= 第二层回滚。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 序㉖:`jitter` 主序与"劣化即切"的**唯一实现**都在 `jitter.ts` ⇒ 本文件只做三件事:
|
||||
* ① 采样(把当前通道的 `rttMs` 喂进 tracker)② 判定(调 `pickJitterTarget`)③ 记数 + 告警。
|
||||
* ⛔ 不许在本文件里再写一份 p95/排序 —— 那正是"另一份实现 = 另一处静默失效"的复发点。
|
||||
*/
|
||||
import {
|
||||
orderByJitter,
|
||||
pickJitterTarget,
|
||||
sharedJitterTracker,
|
||||
type JitterTracker,
|
||||
} from './jitter.js'
|
||||
|
||||
/** 阈值(全部来自参数表 / env;脚本与实现**零数字字面量**)。 */
|
||||
export interface RelayFailoverThresholds {
|
||||
/** 连续失败次数达到这个数即视为不健康。 */
|
||||
@@ -99,7 +111,7 @@ export interface RelayChannelHandle {
|
||||
/** 这条通道连的地址(`ws://` / `wss://`)。 */
|
||||
readonly url: string
|
||||
/** 该通道自己的健康快照(实现里通常就是 `RelayClient.status()` 的投影)。 */
|
||||
health(): { state: string; attempts: number; unhealthyForMs: number }
|
||||
health(): { state: string; attempts: number; unhealthyForMs: number; rttMs?: number }
|
||||
/** 关掉这条通道。**幂等**、不抛。 */
|
||||
close(): void
|
||||
}
|
||||
@@ -121,6 +133,16 @@ export interface RelayFailoverDeps {
|
||||
/** 注入点(单测用);默认 `setTimeout` 自链。 */
|
||||
setTimerImpl?: (fn: () => void, ms: number) => unknown
|
||||
clearTimerImpl?: (handle: unknown) => void
|
||||
/**
|
||||
* **序㉖:抖动采样表**。
|
||||
*
|
||||
* - 缺省(`undefined`)⇒ 用**进程级共享 tracker**({@link sharedJitterTracker})——
|
||||
* 装配点(`src/web/server.ts` / `src/worker/relay-tunnel.ts` / `src/net/relay/main.ts`)
|
||||
* 都**不在序㉖ 的在册文件集**里,做成"必须注入"= 生产上永远不会被注入 = **静默失效**。
|
||||
* - 显式给 `null` ⇒ **本监管器不参与 jitter 排序与劣化切换**(逐字回到序⑧ 行为,夹具用)。
|
||||
* - 集成/单测可传自己的实例(⛔ 别用共享单例做断言 —— 会与别的用例串味)。
|
||||
*/
|
||||
jitterTracker?: JitterTracker | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +184,17 @@ export interface RelayFailoverStats {
|
||||
openFailed: number
|
||||
/** **序⑧ 新增**:走"一跳豁免"完成的切换次数(⊆ `switches`;这些行都带 `|豁免`)。 */
|
||||
exemptSwitches: number
|
||||
/**
|
||||
* **序㉖ 新增**:因 **jitter 劣化**触发的换址次数(⊆ `switches`;这些行都带 `|jitter`)。
|
||||
*
|
||||
* 🔑 为什么必须有这个数:本序的判据是"**超阈值自动切路径并告警**"——如果只写日志不留计数,
|
||||
* 脚本就无法断言"它到底切过没有"(本线已有两次同类教训:只写日志的实现让判据形同虚设)。
|
||||
*/
|
||||
jitterSwitches: number
|
||||
/** **序㉖ 新增**:成功记入 tracker 的 RTT 采样次数(= 0 ⇒ 采样链断了,必须能看出来)。 */
|
||||
jitterSamples: number
|
||||
/** **序㉖ 新增**:当前通道抖动量超标(`p95|ΔRTT| ≥ JITTER_LIMIT_MS`)的巡检次数。 */
|
||||
jitterAlerts: number
|
||||
/** 最近一次成功切换的时刻(epoch ms)。 */
|
||||
lastSwitchAtMs?: number
|
||||
/** 冷却表中的地址与解除时刻(⚠️ 保持 `{url, untilMs}` 外形;`kind` 为序⑧ 追加的只读字段)。 */
|
||||
@@ -175,6 +208,8 @@ export class RelayFailoverSupervisor {
|
||||
private readonly now: () => number
|
||||
private readonly setTimer: (fn: () => void, ms: number) => unknown
|
||||
private readonly clearTimer: (handle: unknown) => void
|
||||
/** 序㉖:抖动采样表(`undefined` = 本序能力关闭 ⇒ 一切逐字回到序⑧ 行为)。 */
|
||||
private readonly jitter: JitterTracker | undefined
|
||||
|
||||
private current: RelayChannelHandle | undefined
|
||||
/** 冷却表:**键 = url**(单一事实:冷却期内该地址不可用);值是 {@link RelayCooldownEntry}(序⑧ 结构化)。 */
|
||||
@@ -184,8 +219,14 @@ export class RelayFailoverSupervisor {
|
||||
private noCandidateChecks = 0
|
||||
private openFailed = 0
|
||||
private exemptSwitches = 0
|
||||
private jitterSwitches = 0
|
||||
private jitterSamples = 0
|
||||
private jitterAlerts = 0
|
||||
private lastJitterLogAtMs: number | undefined
|
||||
private lastSwitchAtMs: number | undefined
|
||||
private lastSkipLogAtMs: number | undefined
|
||||
/** 序㉖:上一次记入 tracker 的样本(用于"同一份缓存读数只记一次"的门限)。 */
|
||||
private lastJitterSample: { url: string; rttMs: number; atMs: number } | undefined
|
||||
private timer: unknown
|
||||
private running = false
|
||||
private ticking = false
|
||||
@@ -208,6 +249,11 @@ export class RelayFailoverSupervisor {
|
||||
})
|
||||
this.clearTimer = deps.clearTimerImpl ?? ((h): void => clearTimeout(h as ReturnType<typeof setTimeout>))
|
||||
this.th = { ...relayFailoverThresholds({}), ...(deps.thresholds ?? {}) }
|
||||
/**
|
||||
* 序㉖:`null` ⇒ 关闭(逐字回到序⑧);`undefined` ⇒ 共享单例(默认,装配点零改动)。
|
||||
* ⚠️ `?? ` 会把 `null` 也当成"没给",所以必须**先显式判 `null`** —— 这是本行唯一的坑。
|
||||
*/
|
||||
this.jitter = deps.jitterTracker === null ? undefined : deps.jitterTracker ?? sharedJitterTracker()
|
||||
}
|
||||
|
||||
/** 当前通道(启动时由装配点灌入第一条)。 */
|
||||
@@ -232,6 +278,9 @@ export class RelayFailoverSupervisor {
|
||||
noCandidateChecks: this.noCandidateChecks,
|
||||
openFailed: this.openFailed,
|
||||
exemptSwitches: this.exemptSwitches,
|
||||
jitterSwitches: this.jitterSwitches,
|
||||
jitterSamples: this.jitterSamples,
|
||||
jitterAlerts: this.jitterAlerts,
|
||||
lastSwitchAtMs: this.lastSwitchAtMs,
|
||||
cooldown: [...this.cooling.entries()]
|
||||
.filter(([, e]) => e.untilMs > now)
|
||||
@@ -373,18 +422,122 @@ export class RelayFailoverSupervisor {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 序㉖:从**当前通道**读一次 RTT 样本并记入 tracker(返回当前通道的 jitter,未知 ⇒ `undefined`)。
|
||||
*
|
||||
* 两级取值:
|
||||
* ① `health().rttMs` —— 接口位(实现方愿意投影就投影);
|
||||
* ② **鸭子类型兜底** `(handle).client.status().rttMs` —— 真实装配点(`src/web/server.ts` 的
|
||||
* `toHandle` 与 `src/worker/relay-tunnel.ts` 的 `healthOf`)**只投影了三个字段**,而它们
|
||||
* **不在序㉖ 的在册文件集**里 ⇒ 兜底读 `client.status()` 是**唯一**能让真机采到样本的路径。
|
||||
* ⚠️ 代价:耦合"句柄身上挂着 client"这个装配事实 ⇒ 用**全可选 + 拿不到就返回 `undefined`**
|
||||
* 兜住:拿不到只是"没样本",⛔ **不抛、不影响换址**。
|
||||
*
|
||||
* 🔴 **缓存门限**:`status().rttMs` 是上次心跳的结果(周期 15 s),而巡检是 2 s 一次 ⇒
|
||||
* 不设门限同一个值会被反复记录、差分恒 0 ⇒ jitter 假绿。门限见 `JITTER_SAMPLE_GAP_MS`。
|
||||
*/
|
||||
private sampleCurrent(cur: RelayChannelHandle): number | undefined {
|
||||
const j = this.jitter
|
||||
if (j === undefined || !j.enabled) return undefined
|
||||
let rtt = cur.health().rttMs
|
||||
if (typeof rtt !== 'number' || !Number.isFinite(rtt)) {
|
||||
const duck = (cur as { client?: { status?: () => { rttMs?: number } } }).client
|
||||
const st = typeof duck?.status === 'function' ? duck.status() : undefined
|
||||
rtt = st?.rttMs
|
||||
}
|
||||
if (typeof rtt !== 'number' || !Number.isFinite(rtt)) return j.jitterMs(cur.url)
|
||||
const now = this.now()
|
||||
const last = this.lastJitterSample
|
||||
const fresh =
|
||||
last === undefined ||
|
||||
last.url !== cur.url ||
|
||||
last.rttMs !== rtt ||
|
||||
now - last.atMs >= j.thresholds().sampleGapMs
|
||||
if (fresh) {
|
||||
if (j.record(cur.url, rtt)) {
|
||||
this.jitterSamples += 1
|
||||
this.lastJitterSample = { url: cur.url, rttMs: rtt, atMs: now }
|
||||
}
|
||||
}
|
||||
return j.jitterMs(cur.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 序㉖:**"jitter 劣化即切"**(`E2`)—— 通道**健康但抖得厉害**时,换到更稳的候选。
|
||||
*
|
||||
* ⛔ 三条边界(都是本线已有判据,⛔ 不许动):
|
||||
* 1. **不碰冷却语义**:候选池仍要过 `blocked`(当前 + 冷却中的)⇒ jitter 换址**没有**打破
|
||||
* 冷却的权力(豁免权只属于"当前这条已经挂了"那条语义)。
|
||||
* 2. **没有更稳的候选 ⇒ 原地不动**(不切空、不静默回退默认机)。
|
||||
* 3. 换址**仍走唯一的 {@link replace}** ⇒ `[relay-switch]` 行数与 `switches` 的相等关系
|
||||
* (D7 判别器)不受影响。
|
||||
*/
|
||||
private async considerJitterSwitch(cur: RelayChannelHandle, curJitterMs: number | undefined): Promise<void> {
|
||||
const j = this.jitter
|
||||
if (j === undefined || curJitterMs === undefined) return
|
||||
const jth = j.thresholds()
|
||||
if (curJitterMs < jth.switchMs) return
|
||||
this.jitterAlerts += 1
|
||||
const now = this.now()
|
||||
/** 告警按 `graceMs` 节流(否则每次巡检一行 = 日志被刷满,本线吃过这个亏)。 */
|
||||
if (this.lastJitterLogAtMs === undefined || now - this.lastJitterLogAtMs >= this.th.graceMs) {
|
||||
this.lastJitterLogAtMs = now
|
||||
this.log(
|
||||
`[relay-jitter] ⚠ 当前通道抖动量超标(p95|ΔRTT|=${curJitterMs}ms ≥ 阈值 ${jth.switchMs}ms,` +
|
||||
`样本 ${this.jitterSamples} 个)⇒ 尝试换到更稳的候选(url=${cur.url})`,
|
||||
)
|
||||
}
|
||||
let urls: readonly string[]
|
||||
try {
|
||||
urls = await this.deps.candidates()
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
this.log(`[relay-jitter] ⚠ 候选链解析失败(${msg})⇒ 本次不换址(原地不动)`)
|
||||
return
|
||||
}
|
||||
const blocked = new Set<string>([cur.url])
|
||||
for (const [url, e] of this.cooling) if (e.untilMs > now) blocked.add(url)
|
||||
const best = pickJitterTarget({
|
||||
urls,
|
||||
tracker: j,
|
||||
curUrl: cur.url,
|
||||
curJitterMs,
|
||||
switchMs: jth.switchMs,
|
||||
blocked,
|
||||
})
|
||||
if (best === undefined) {
|
||||
this.log(
|
||||
`[relay-jitter] ⤵ 无更稳的候选(候选 ${urls.length} 条,可用 ${urls.filter((u) => !blocked.has(u)).length} 条)⇒ 保持当前通道`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const ok = await this.replace(
|
||||
best.url,
|
||||
`当前通道抖动量超标(p95|ΔRTT|=${curJitterMs}ms ≥ 阈值 ${jth.switchMs}ms)且 ${best.url} 更稳(${best.jitterMs}ms)`,
|
||||
'health',
|
||||
)
|
||||
if (ok) this.jitterSwitches += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次巡检:**当前通道不健康 ⇒ 换到链里的下一条**(排除当前 + 冷却中的)。
|
||||
*
|
||||
* ⛔ 不健康但无候选 ⇒ 序⑧ 之前是**什么都不做**(D6:原地退避,⛔ 不切到空 / 不静默回退默认机);
|
||||
* 序⑧ 起:**先试一次"一跳豁免"**(D1/D4/D5),拿不到豁免对象才回到原地退避。
|
||||
*
|
||||
* 序㉖:**每次巡检都先采一个 RTT 样本**(不论健康与否 —— 直方图没数据就判不出"劣化"),
|
||||
* 健康时额外判一次"**抖动劣化即切**"({@link considerJitterSwitch})。
|
||||
*/
|
||||
async tick(): Promise<void> {
|
||||
this.checks += 1
|
||||
const cur = this.current
|
||||
if (cur === undefined) return
|
||||
const h = cur.health()
|
||||
if (!this.unhealthy(h)) return
|
||||
const curJitterMs = this.sampleCurrent(cur)
|
||||
if (!this.unhealthy(h)) {
|
||||
await this.considerJitterSwitch(cur, curJitterMs)
|
||||
return
|
||||
}
|
||||
|
||||
const now = this.now()
|
||||
let urls: readonly string[]
|
||||
@@ -404,7 +557,11 @@ export class RelayFailoverSupervisor {
|
||||
const reason =
|
||||
`当前通道不健康(state=${h.state} attempts=${h.attempts} unhealthyForMs=${h.unhealthyForMs}` +
|
||||
` ≥ 阈值 minAttempts=${this.th.minAttempts}/graceMs=${this.th.graceMs})`
|
||||
const target = urls.find((u) => !blocked.has(u))
|
||||
/**
|
||||
* 序㉖:**候选顺序 = jitter 为主序**(`E1`)。⚠️ tracker 无样本时 `orderByJitter` 返回
|
||||
* **原数组本身** ⇒ 与改造前逐字一致(`D3` 的护栏因此仍然成立)。
|
||||
*/
|
||||
const target = orderByJitter(urls, this.jitter).find((u) => !blocked.has(u))
|
||||
if (target !== undefined) {
|
||||
await this.replace(target, reason, 'health')
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user