Files
dsh_shenxian/src/net/relay/placement.ts
T

209 lines
9.3 KiB
TypeScript
Raw Normal View History

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