覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)
验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
210 lines
9.3 KiB
TypeScript
210 lines
9.3 KiB
TypeScript
/**
|
||
* 节点选点(placement)—— **"加入哪个节点"这件事的唯一判据来源**。
|
||
*
|
||
* ## 为什么要有独立一层
|
||
* 「自动选择适合的节点」如果散在 client / Manager / 门户里各写一遍,就会出现三套不一致的
|
||
* 判据(这类不一致在复盘里反复出现)。所以:**判据只有这一处,纯函数、无 IO、可单测**。
|
||
*
|
||
* ## 两条硬规则(先说结论)
|
||
* 1. **手动选择永远优先**(`manual: true`)—— 显式指定就不该被算法偷偷改掉;
|
||
* 手动指定的节点**满了** ⇒ 返回 `queued`(排队等待)或 `rejected`,**不会静默换一个**。
|
||
* 2. **满载是唯一的硬门**(`capacity.free === 0` ⇒ 不可选)。其余(RTT 高、近期失败多)
|
||
* 只**降权**,不排除 —— 因为"唯一可用但慢"的节点也远好过"没有节点"。
|
||
*
|
||
* ## 排序依据(用户要求的两条:速度 + 负载)
|
||
* ```text
|
||
* speed = 100 / (1 + rttMs / rttHalfMs) // rtt 0→100 分;50ms→50 分;200ms→20 分
|
||
* load = 100 × (1 − used / max) // 容量未知 ⇒ 按 0.5 中性处理(不假装它空)
|
||
* score = weight × (wSpeed·speed + wLoad·load) − failurePenalty × recentFailures
|
||
* ```
|
||
* 权重默认 `速度 0.55 / 负载 0.45`:实测里"能不能连上"由 RTT 决定,"连上之后卡不卡"由负载决定,
|
||
* 而我们的第一瓶颈是 **presence(在线态)**,所以速度略重(传输方案 §12 的实测依据)。
|
||
*
|
||
* ## 与 relay 的关系
|
||
* 本模块**不连网、不开端口**:它只把"可观测画像"变成"一次可解释的选择"。
|
||
* relay 侧只负责把画像喂进来(`rttMs` 来自心跳 PONG,`capacity` 来自注册数)。
|
||
*
|
||
* @module dshs/net/relay/placement
|
||
*/
|
||
|
||
/** 一个候选节点(relay 或 worker,判据同构)的**可观测**画像。 */
|
||
export interface NodeCandidate {
|
||
id: string
|
||
/** 往返时延(ms)。未知 ⇒ 不猜,按"最差但有值"处理(见 `assumedRttMs`)。 */
|
||
rttMs?: number
|
||
/** 容量。`max <= 0` 或缺省 ⇒ **不限**(视为有空位,负载按中性算)。 */
|
||
capacity?: { max: number; used: number }
|
||
/** 近期失败次数(连接失败 / 被拒)—— 用于降权,**不用于排除**。 */
|
||
recentFailures?: number
|
||
/** 手工指定的节点。**最高优先级**:只要它没满就一定选它。 */
|
||
manual?: boolean
|
||
/** 额外权重(例如"存量锚点"、"同机房");`1` = 中性。 */
|
||
weight?: number
|
||
}
|
||
|
||
export interface PlacementWeights {
|
||
/** 速度权重。默认 0.55。 */
|
||
speed?: number
|
||
/** 负载权重。默认 0.45。 */
|
||
load?: number
|
||
/** 每次近期失败的扣分。默认 15。 */
|
||
failurePenalty?: number
|
||
/** 速度评分的半衰 RTT(ms)。默认 50。 */
|
||
rttHalfMs?: number
|
||
/** RTT 未知时的代用值(ms)。默认 120(明显偏保守,避免"没测速"被当成"很快")。 */
|
||
assumedRttMs?: number
|
||
/** 全部满载时的建议重试间隔(ms)。默认 5000。 */
|
||
queueRetryAfterMs?: number
|
||
}
|
||
|
||
export interface PlacementScore {
|
||
id: string
|
||
/** 0–100(负分表示被降权到负);`-Infinity` = 硬门拦住。 */
|
||
score: number
|
||
/** 硬门原因。只有 `full` 是硬门。 */
|
||
blocked?: 'full'
|
||
detail: {
|
||
speed: number
|
||
load: number
|
||
penalty: number
|
||
/** 剩余空位;容量未知时 `undefined`。 */
|
||
freeSlots?: number
|
||
/** 负载比例 0–1。 */
|
||
loadRatio?: number
|
||
}
|
||
}
|
||
|
||
export interface PlacementDecision {
|
||
/** 选中的节点。全部满载 ⇒ `undefined`。 */
|
||
chosen?: NodeCandidate
|
||
/** 结论形态:选中 / 排队等待 / 直接拒绝(无任何可选且不适合排队)。 */
|
||
outcome: 'chosen' | 'queued' | 'rejected'
|
||
/** 排队时建议的等待时长(ms)。 */
|
||
retryAfterMs?: number
|
||
/** **可解释**的理由(这个字段就是本模块存在的意义:拒绝也要说清为什么)。 */
|
||
reason: string
|
||
/** 完整排名(含被拦的),便于前端展示与排障。 */
|
||
ranking: PlacementScore[]
|
||
}
|
||
|
||
const DEFAULTS = {
|
||
speed: 0.55,
|
||
load: 0.45,
|
||
failurePenalty: 15,
|
||
rttHalfMs: 50,
|
||
assumedRttMs: 120,
|
||
queueRetryAfterMs: 5_000,
|
||
} as const
|
||
|
||
function clampScore(n: number): number {
|
||
return Math.max(-100, Math.min(100, n))
|
||
}
|
||
|
||
/** 给一个候选打分(纯函数;导出便于单测与前端复用同一套判据)。 */
|
||
export function scoreCandidate(c: NodeCandidate, w: PlacementWeights = {}): PlacementScore {
|
||
const W = { ...DEFAULTS, ...w }
|
||
const rtt = typeof c.rttMs === 'number' && c.rttMs >= 0 ? c.rttMs : W.assumedRttMs
|
||
const speed = clampScore((100 / (1 + rtt / W.rttHalfMs)) | 0)
|
||
|
||
const max = c.capacity !== undefined ? c.capacity.max : 0
|
||
const used = c.capacity !== undefined ? c.capacity.used : 0
|
||
const limited = max > 0
|
||
const freeSlots = limited ? Math.max(0, max - used) : undefined
|
||
// 容量未知 ⇒ 按 0.5 中性:既不当成空、也不当成满(不猜)。
|
||
const loadRatio = limited ? Math.min(1, used / max) : 0.5
|
||
const load = clampScore(Math.round(100 * (1 - loadRatio)))
|
||
|
||
const penalty = Math.round((c.recentFailures ?? 0) * W.failurePenalty)
|
||
const weight = typeof c.weight === 'number' && c.weight > 0 ? c.weight : 1
|
||
const raw = weight * (W.speed * speed + W.load * load) - penalty
|
||
|
||
const base = {
|
||
id: c.id,
|
||
detail: {
|
||
speed,
|
||
load,
|
||
penalty,
|
||
...(freeSlots === undefined ? {} : { freeSlots }),
|
||
...(limited ? { loadRatio } : {}),
|
||
},
|
||
}
|
||
// **唯一的硬门**:容量已满。满了就是不能加入 —— 只能排队或换节点。
|
||
if (freeSlots === 0) return { ...base, score: Number.NEGATIVE_INFINITY, blocked: 'full' }
|
||
return { ...base, score: Math.round(raw * 100) / 100 }
|
||
}
|
||
|
||
/** 全量排名(降序;`-Infinity` 排在最后)。 */
|
||
export function rankCandidates(candidates: readonly NodeCandidate[], w: PlacementWeights = {}): PlacementScore[] {
|
||
return candidates.map((c) => scoreCandidate(c, w)).sort((a, b) => b.score - a.score)
|
||
}
|
||
|
||
export interface ChooseOptions extends PlacementWeights {
|
||
/**
|
||
* 手动指定的 id。给了它 ⇒ **只考虑它**(不静默改选别的)。
|
||
* 这也是"手动选择"与"自动推荐"之间唯一的接口。
|
||
*/
|
||
manualId?: string
|
||
/** 允许排队等待(默认 `true`)。设 `false` ⇒ 满载直接 `rejected`("不能加入")。 */
|
||
allowQueue?: boolean
|
||
}
|
||
|
||
/**
|
||
* 选一个节点 —— **自动 / 手动 / 排队**三种出口都有明确语义:
|
||
* | 情况 | outcome | 说明 |
|
||
* |---|---|---|
|
||
* | 手动指定且未满 | `chosen` | 尊重显式选择 |
|
||
* | 手动指定但已满 | `queued` / `rejected` | **不会**偷偷换节点 |
|
||
* | 自动且有空位 | `chosen` | 按速度 + 负载打分 |
|
||
* | 自动但全满 | `queued` / `rejected` | 排队等位,或(`allowQueue:false`)拒绝加入 |
|
||
* | 候选为空 | `rejected` | 明确"没有可选节点",不抛异常给上层去猜 |
|
||
*/
|
||
export function chooseNode(candidates: readonly NodeCandidate[], opts: ChooseOptions = {}): PlacementDecision {
|
||
const allowQueue = opts.allowQueue !== false
|
||
const retryAfterMs = opts.queueRetryAfterMs ?? DEFAULTS.queueRetryAfterMs
|
||
const ranking = rankCandidates(candidates, opts)
|
||
|
||
if (ranking.length === 0) {
|
||
return { outcome: 'rejected', reason: '没有可用候选节点(候选列表为空)', ranking }
|
||
}
|
||
|
||
const manualId = opts.manualId
|
||
if (manualId !== undefined && manualId !== '') {
|
||
const hit = ranking.find((r) => r.id === manualId)
|
||
if (hit === undefined) {
|
||
return { outcome: 'rejected', reason: `手动指定的节点 "${manualId}" 不在候选列表里(不静默改选别的节点)`, ranking }
|
||
}
|
||
if (hit.blocked === 'full') {
|
||
return allowQueue
|
||
? { outcome: 'queued', retryAfterMs, reason: `手动指定的节点 "${manualId}" 已满载(容量已用尽)⇒ 排队等待`, ranking }
|
||
: { outcome: 'rejected', reason: `手动指定的节点 "${manualId}" 已满载 ⇒ 拒绝加入(未开启排队)`, ranking }
|
||
}
|
||
return { chosen: candidates.find((c) => c.id === manualId), outcome: 'chosen', reason: `按手动指定选择 "${manualId}"`, ranking }
|
||
}
|
||
|
||
const best = ranking[0]
|
||
if (best.blocked !== 'full') {
|
||
const c = candidates.find((x) => x.id === best.id)
|
||
return {
|
||
chosen: c,
|
||
outcome: 'chosen',
|
||
reason:
|
||
`自动选择 "${best.id}"(速度 ${best.detail.speed} 分 / 负载 ${best.detail.load} 分` +
|
||
`${best.detail.freeSlots === undefined ? '' : ` / 空位 ${best.detail.freeSlots}`}` +
|
||
`${best.detail.penalty > 0 ? ` / 近期失败扣 ${best.detail.penalty}` : ''})`,
|
||
ranking,
|
||
}
|
||
}
|
||
|
||
const fullCount = ranking.filter((r) => r.blocked === 'full').length
|
||
return allowQueue
|
||
? { outcome: 'queued', retryAfterMs, reason: `全部 ${fullCount} 个候选节点都已满载 ⇒ 排队等待(${retryAfterMs}ms 后重试)`, ranking }
|
||
: { outcome: 'rejected', reason: `全部 ${fullCount} 个候选节点都已满载 ⇒ 拒绝加入(未开启排队)`, ranking }
|
||
}
|
||
|
||
/** 供日志/UI 一行展示(**同一条信息只说一次**,避免各处自定义格式)。 */
|
||
export function describeDecision(d: PlacementDecision): string {
|
||
const head = `placement=${d.outcome}${d.chosen === undefined ? '' : ` node=${d.chosen.id}`}`
|
||
const retry = d.retryAfterMs === undefined ? '' : ` retryAfter=${d.retryAfterMs}ms`
|
||
return `${head}${retry} reason="${d.reason}"`
|
||
}
|