Files
dsh_shenxian/src/net/relay/placement.ts
T
admin 146c3d25ef feat(overlay): 覆盖网络线序①–⑮ 代码与测试产物入库
覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 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
2026-09-17 17:03:27 +08:00

210 lines
9.3 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.
/**
* 节点选点(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}"`
}