Files
dsh_shenxian/src/net/relay/jitter.ts
T
admin 09ce76f3af 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)
2026-09-18 08:08:51 +08:00

336 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 覆盖网络 · **链路抖动(jitter)采样 · 直方图 · 选路主序**(序 ㉖ · 骨干稳定选路与加密)。
*
* ## 为什么需要它(用户口径 → 机器判据)
*
* 用户 2026-09-17 20:2x 的口径是「**按照连接稳定高效的方式 数据安全可加密传输**」。翻译成
* 可验证的工程质量判据就是 **"选路看 jitter、⛔ 不看 RTT"**:
*
* - 改造前:`directory.ts` 的候选序 = **签名目录发布序**(同源优先插首位),`switcher.ts` 的换址
* = **排除当前 + 排除冷却中的 ⇒ 取链里下一个** ⇒ 两处**都没有**"哪条更稳"这个维度。
* - 后果:一条 RTT 低但**抖得厉害**的路径会长期霸占首位 —— 而"稳定"恰恰是交互式会话的第一诉求
* (RTT 高只是慢,jitter 高是**卡顿/超时/断连**)。
*
* ## 三条口径(⛔ 改这三条等于改判据,必须同步改参数表)
*
* 1. **量 = `|ΔRTT|` 的 p95**(相邻两次心跳往返之差的绝对值),与 `scripts/overlay-jitter.cjs`
* 序⑥ 实测所用的**同一个量**(`p95AbsDelta`)⇒ 历史读数(`p95 = 3 ms`)与本模块**同口径可比**。
* 2. **序 = jitter 升序**,且 **只对"已测出样本"的候选生效**;**无样本者保原序排在其后**
* (⛔ 不惩罚"还没测过的备用中继",也不凭空给它排位 —— 见 {@link orderByJitter})。
* 3. **零样本 ⇒ 逐字返回原数组**(⛔ 这是零回归的机器判据 `D9`:观测器没喂过数,
* 行为必须与改造前**逐字一致**)。
*
* ## 为什么只有这一份算法
*
* relay 侧(`server.ts` 的每会话 RTT)与平台侧(`switcher.ts` 的选路)**共用**本模块的
* `absDeltas` / `percentile` / `histogram` —— 本线的教训是「**另一份实现 = 另一处静默失效**」
* (取址链踩过两次)。⛔ 不许在 `scripts/**` 或别的模块里再写一份 p95/直方图。
*
* ## 阈值来源(⛔ 全部来自参数表,模块内零魔数)
*
* `JITTER_ENABLE` / `JITTER_SAMPLE_MAX` / `JITTER_MIN_SAMPLES` / **`JITTER_LIMIT_MS`** /
* `JITTER_HIST_MAX_MS` / `JITTER_HIST_BUCKETS` / `JITTER_SAMPLE_GAP_MS`
* (口径见 `参数表_覆盖网络_20260917.md §3.7`)。
*
* 🔴 **劣化阈值复用 `JITTER_LIMIT_MS`(⛔ 不新造 `JITTER_SWITCH_MS`)**:该键在序⑥ 就已登记
* (值 `20 ms`,语义 = `p95(|ΔRTT|)` 的**达标限值**)—— "超标"与"劣化到该换路"是**同一件事**
* ⇒ 新造一个同值键只会变成"同一事实两处写"(本线的知识碎片化教训)。
*
* @module src/net/relay/jitter
*/
/** 本模块的阈值(**全部**来自参数表;见模块头)。 */
export interface JitterThresholds {
/** 总开关(`JITTER_ENABLE`,默认 `1`)。置 `0` ⇒ 采样与排序**全部失效**回到改造前行为。 */
enabled: boolean
/** 每个 url 保留的 RTT 样本上限(环状,老的丢弃)。 */
sampleMax: number
/** 参与排序 / 劣化判定所需的**最小 |ΔRTT| 样本数**(不足 ⇒ 该 url 视为"未知")。 */
minSamples: number
/**
* **劣化阈值**:`p95(|ΔRTT|) ≥ 它` ⇒ 这条路径被判"不稳",允许换到更稳的候选。
*
* ⚠️ 来源 = 参数表的 **`JITTER_LIMIT_MS`**(序⑥ 就有的"达标限值";⛔ 不是新键)。
*/
switchMs: number
/** 直方图上界(`≥ 它` 的样本落进末桶)。 */
histMaxMs: number
/** 直方图桶数(**固定值**;探针拿它校验"口径一致",⛔ 不是实现细节)。 */
histBuckets: number
/**
* **同一份缓存读数的最小采样间隔(ms)**。
*
* 🔴 为什么必须有这个键:`RelayClient.status().rttMs` 是**上一次心跳的结果**,而心跳周期
* (`HB_SEC = 15 s`)远大于巡检周期(`checkMs = 2 s`)⇒ 不做门限的话**同一个 RTT 值会被
* 反复记录** ⇒ 差分恒为 0 ⇒ **jitter 被系统性低估到 0**(判据假绿)。
* ⇒ 门限必须 **> 心跳周期**:同一份缓存值最多只贡献一个样本。
*
* ⚠️ relay 侧(`server.ts`)**不用**这个键 —— 它在 `PONG` 到达那一刻采样,本来就是新测量。
*/
sampleGapMs: number
}
/**
* 从 env 读阈值(**值格必须纯数字**;非纯数字一律回退默认值)。
*
* ⚠️ 与 `relayFailoverThresholds` 同款纪律:参数表里写 `1` / `0`,⛔ **不许**写 `true` 或
* 带夹注的 `1(默认)` —— 后者解析失败会**静默回退默认值**(本线已踩过一次)。
*/
export function jitterThresholds(env: Record<string, string | undefined> = process.env): JitterThresholds {
const num = (key: string, dflt: number): number => {
const raw = (env[key] ?? '').trim()
if (raw === '') return dflt
return /^\d+$/.test(raw) ? Number(raw) : dflt
}
return {
enabled: num('JITTER_ENABLE', 1) !== 0,
sampleMax: num('JITTER_SAMPLE_MAX', 32),
minSamples: num('JITTER_MIN_SAMPLES', 3),
switchMs: num('JITTER_LIMIT_MS', 20),
histMaxMs: num('JITTER_HIST_MAX_MS', 200),
histBuckets: num('JITTER_HIST_BUCKETS', 8),
sampleGapMs: num('JITTER_SAMPLE_GAP_MS', 20_000),
}
}
/**
* 相邻样本的一阶差分绝对值 = **抖动量**(⛔ 不是标准差)。
*
* 🔴 为什么不用标准差:标准差会把"单调漂移"(排队时延缓慢变化)算成抖动,而交互式会话真正
* 怕的是**相邻两拍之间的突变**(卡一下)。序⑥ 的实测口径同样是相邻差分 ⇒ 保持一致。
*/
export function absDeltas(samples: readonly number[]): number[] {
const out: number[] = []
for (let i = 1; i < samples.length; i++) {
const d = Math.abs(samples[i]! - samples[i - 1]!)
if (Number.isFinite(d)) out.push(d)
}
return out
}
/**
* 百分位(**与 `scripts/overlay-jitter.cjs` 逐字同口径**:`sorted[min(len-1, floor(len·p))]`)。
*
* ⚠️ 口径必须与脚本一致,否则"实时选路看到的 p95"与"运维点测的 p95"会给出**两个数**。
*/
export function percentile(values: readonly number[], p: number): number {
if (values.length === 0) return 0
const s = [...values].sort((a, b) => a - b)
return s[Math.min(s.length - 1, Math.floor(s.length * p))]!
}
/** 直方图(等宽桶;`≥ histMaxMs` 落末桶)。返回值长度**恒等于** `buckets`。 */
export function histogram(deltas: readonly number[], histMaxMs: number, buckets: number): number[] {
const out = new Array<number>(Math.max(1, buckets)).fill(0)
if (histMaxMs <= 0) return out
const n = out.length
for (const d of deltas) {
const idx = Math.min(n - 1, Math.max(0, Math.floor((d / histMaxMs) * n)))
out[idx] = (out[idx] ?? 0) + 1
}
return out
}
/** 一组样本的抖动画像。 */
export interface JitterStats {
/** 已采到的 RTT 样本数。 */
samples: number
/** `|ΔRTT|` 样本数(= `samples - 1`,样本不足 2 时为 0)。 */
deltas: number
p95AbsDeltaMs: number
meanAbsDeltaMs: number
maxAbsDeltaMs: number
/** 直方图(长度 = `histBuckets`)。 */
hist: number[]
}
/** 由**已在别处算好的差分序列**构造画像(relay 侧多会话合并时用;⛔ 不重复实现统计)。 */
export function statsFromDeltas(deltas: readonly number[], th: JitterThresholds): JitterStats {
const sum = deltas.reduce((a, b) => a + b, 0)
return {
samples: deltas.length + 1,
deltas: deltas.length,
p95AbsDeltaMs: round2(percentile(deltas, 0.95)),
meanAbsDeltaMs: deltas.length === 0 ? 0 : round2(sum / deltas.length),
maxAbsDeltaMs: deltas.length === 0 ? 0 : round2(Math.max(...deltas)),
hist: histogram(deltas, th.histMaxMs, th.histBuckets),
}
}
function round2(v: number): number {
return Math.round(v * 100) / 100
}
/**
* **每个 url 一份的 RTT 样本环 + 抖动画像**(进程内单例,见 {@link sharedJitterTracker})。
*
* ⛔ 它**不做 I/O**、不定时、不联网 —— 采样由调用方喂(relay 侧 = PONG 回来的那一刻;
* 平台侧 = 当前通道的 `status().rttMs`)。这样本模块可以在单测里被**逐项断言**。
*/
export class JitterTracker {
private readonly th: JitterThresholds
private readonly rings = new Map<string, number[]>()
constructor(th: Partial<JitterThresholds> = {}) {
this.th = { ...jitterThresholds(), ...th }
}
/** 阈值快照(调用方据此做判定,⛔ 不许各自再读一遍 env)。 */
thresholds(): JitterThresholds {
return this.th
}
/** 是否开启(`JITTER_ENABLE=0` ⇒ 采样与排序全部失效)。 */
get enabled(): boolean {
return this.th.enabled
}
/**
* 记一次 RTT 样本。
*
* 非法输入(非有限数 / 负数)**静默丢弃并返回 `false`** —— 这里故意不抛:
* 采样点在生产路径上(每 15 s 一次心跳),抛异常会把**选路**带崩,
* 而"少一个样本"只影响排序精度。⛔ 但**不静默吞掉"整条通道读不到 RTT"**:那是
* `switcher.ts` 的 `jitterAlerts` / 探针 `OBS-19` 负责点名的分工。
*/
record(url: string, rttMs: number): boolean {
if (!this.th.enabled) return false
if (url === '' || !Number.isFinite(rttMs) || rttMs < 0) return false
const ring = this.rings.get(url) ?? []
ring.push(rttMs)
const max = Math.max(2, this.th.sampleMax)
if (ring.length > max) ring.splice(0, ring.length - max)
this.rings.set(url, ring)
return true
}
/** 该 url 的画像;**样本不足 `minSamples` 个差分 ⇒ `undefined`(= 未知,⛔ 不当 0 用)**。 */
stats(url: string): JitterStats | undefined {
const ring = this.rings.get(url)
if (ring === undefined || ring.length < 2) return undefined
const st = statsFromDeltas(absDeltas(ring), this.th)
if (st.deltas < this.th.minSamples) return undefined
return st
}
/** 该 url 的 jitter(`undefined` = 未知)。 */
jitterMs(url: string): number | undefined {
return this.stats(url)?.p95AbsDeltaMs
}
/** 已采过样的 url(信息输出 / 观测用)。 */
urls(): string[] {
return [...this.rings.keys()].sort()
}
/** 全量快照(**只读**副本;给 `/status` 与探针用)。 */
snapshot(): { url: string; samples: number; jitterMs?: number }[] {
return this.urls().map((url) => {
const st = this.stats(url)
return {
url,
samples: this.rings.get(url)?.length ?? 0,
...(st === undefined ? {} : { jitterMs: st.p95AbsDeltaMs }),
}
})
}
/** 清空(测试与"配置热更"用;⛔ 生产路径不调它)。 */
reset(): void {
this.rings.clear()
}
}
/**
* **候选排序:jitter 为主序**(`E1` 的实现本体)。
*
* 语义(⛔ 三条都要照做,改一条就等于改判据):
* 1. **已测出样本**(差分 ≥ `minSamples`)的候选按 `jitter` **升序**在前 —— 并列时**保原相对序**;
* 2. **未测出样本**的候选按**原相对序**排在其后 —— ⛔ 不把"没测过"当成"很差"(那会让**备用中继
* 永远排最后 ⇒ 永远不被使用 ⇒ 永远测不出来**,形成死角),也⛔ 不把它当成"很好"(那会让
* 推荐序失去意义);
* 3. **一个都没测出来 ⇒ 返回原数组本身**(`D9`:零回归的机器判据)。
*
* ⚠️ 排序必须 **stable**(同 key 保原序)—— 否则同源优先、目录发布序这些**已有语义**会被
* 一次抖动采样随机洗牌 ⇒ 那是净退化(R11)。
*/
export function orderByJitter(
urls: readonly string[],
tracker: JitterTracker | undefined,
minSamples?: number,
): readonly string[] {
if (tracker === undefined || !tracker.enabled || urls.length < 2) return urls
const th = tracker.thresholds()
const need = minSamples ?? th.minSamples
const known: { url: string; jitterMs: number; idx: number }[] = []
for (let i = 0; i < urls.length; i++) {
const url = urls[i]!
const st = tracker.stats(url)
if (st === undefined || st.deltas < need) continue
known.push({ url, jitterMs: st.p95AbsDeltaMs, idx: i })
}
/** ③ 零已知 ⇒ 原数组(⛔ 连新数组都不建:`D9` 要的是"逐字一致")。 */
if (known.length === 0) return urls
known.sort((a, b) => (a.jitterMs === b.jitterMs ? a.idx - b.idx : a.jitterMs - b.jitterMs))
const pinned = new Set(known.map((k) => k.url))
return [...known.map((k) => k.url), ...urls.filter((u) => !pinned.has(u))]
}
/**
* **"jitter 劣化即切"的挑人单点判据**(`E2` 的实现本体)。
*
* 规则(⛔ 只有这一处实现;`switcher.ts` 只负责调用 + 记数 + 告警):
* - 当前通道 jitter `< switchMs` ⇒ `undefined`(**没劣化,一步都不许动**);
* - 否则在候选里找 **① 不是当前 ② 不在 `blocked` 里**(`blocked` = 当前 + 冷却中的,
* ⛔ **jitter 换址没有打破冷却的权力** —— 豁免权只属于"当前这条已经挂了"这条语义,见 `switcher.ts`)
* 且 **jitter 已知且严格更小** 的最小者;
* - 找不到 ⇒ `undefined`(**原地不动**,⛔ 不切空、⛔ 不静默回退默认机)。
*/
export function pickJitterTarget(opts: {
urls: readonly string[]
tracker: JitterTracker | undefined
curUrl: string
curJitterMs: number
switchMs: number
minSamples?: number
blocked?: ReadonlySet<string>
}): { url: string; jitterMs: number } | undefined {
const { urls, tracker, curUrl, curJitterMs, switchMs, blocked } = opts
if (tracker === undefined || !tracker.enabled) return undefined
if (!Number.isFinite(curJitterMs) || curJitterMs < switchMs) return undefined
const need = opts.minSamples ?? tracker.thresholds().minSamples
let best: { url: string; jitterMs: number } | undefined
for (const url of urls) {
if (url === curUrl) continue
if (blocked !== undefined && blocked.has(url)) continue
const st = tracker.stats(url)
if (st === undefined || st.deltas < need) continue
const j = st.p95AbsDeltaMs
if (j >= curJitterMs) continue
if (best === undefined || j < best.jitterMs) best = { url, jitterMs: j }
}
return best
}
/**
* **进程级共享 tracker**(`switcher.ts` 采样 / `directory.ts` 排序 / relay `/status` 观测
* 都用它 ⇒ 装配点**零改动**)。
*
* 🔴 为什么必须是单例:装配点(`src/web/server.ts` / `src/worker/relay-tunnel.ts` /
* `src/net/relay/main.ts`)**不在序㉖ 的在册文件集**内 ⇒ 若把 tracker 做成"构造时注入",
* 生产上**永远不会被注入** ⇒ 本序所有判据都变成**静默失效**(装了但一次都没生效)。
* 单例把"接线"这件事**从装配点挪进模块内部**,代价是"测试要能换掉它" ⇒ 见
* {@link setSharedJitterTracker}。
*/
let shared: JitterTracker | undefined
export function sharedJitterTracker(): JitterTracker {
if (shared === undefined) shared = new JitterTracker()
return shared
}
/** 注入/清空共享 tracker(**只有单测用**)。 */
export function setSharedJitterTracker(t: JitterTracker | undefined): void {
shared = t
}