Files
MikrotikManager/backend/src/modules/mcp/routing-model.ts
T

99 lines
11 KiB
TypeScript

import type { RouteAiProfile, RouteObservation } from "@mmapp/contracts/mcp-routing"
export function fresh(timestamp: string | null, seconds: number, now: number): boolean {
const measured = timestamp === null ? NaN : Date.parse(timestamp)
return Number.isFinite(measured) && measured <= now && now - measured <= seconds * 1000
}
export function rankRoutingCandidates(profile: RouteAiProfile, observations: RouteObservation[], now = Date.now()) {
function leg(src: number, dst: number, iface: string, remoteIface?: string) {
const possible = observations.flatMap(observation => {
if (observation.family !== profile.family || observation.table !== profile.table) return []
if (observation.srcServerId === src && observation.dstServerId === dst && observation.srcInterface === iface && (!remoteIface || observation.dstInterface === remoteIface)) return [observation]
if (observation.srcServerId === dst && observation.dstServerId === src && observation.dstInterface === iface && (!remoteIface || observation.srcInterface === remoteIface)) return [{ ...observation, dlMbps: observation.ulMbps, ulMbps: observation.dlMbps }]
return []
})
const peerInterfaces = new Set(possible.map(value => value.srcServerId === src ? value.dstInterface : value.srcInterface))
if (!remoteIface && peerInterfaces.size > 1) return undefined
// Collectors run independently. A new ping must not erase a still fresh
// bandwidth sample for the same exact directional link/family/table.
const latest = (field: "pingAt" | "bandwidthAt") => possible.filter(value => value[field] !== null || value.sourceVerified === false)
.sort((a, b) => Date.parse(b.recordedAt ?? b[field]!) - Date.parse(a.recordedAt ?? a[field]!)
|| (b.recordedAt ?? b[field] ?? "").localeCompare(a.recordedAt ?? a[field] ?? ""))[0]
const ping = latest("pingAt"), bandwidth = latest("bandwidthAt"), base = ping ?? bandwidth
return base ? { ...base, pingMs: ping?.contextVerified ? ping.pingMs : null, lossPct: ping?.contextVerified ? ping.lossPct : null, pingAt: ping?.pingAt ?? null,
dlMbps: bandwidth?.contextVerified ? bandwidth.dlMbps : null, ulMbps: bandwidth?.contextVerified ? bandwidth.ulMbps : null, bandwidthAt: bandwidth?.bandwidthAt ?? null,
sampleIds: [...new Set([ping?.id, bandwidth?.id].filter((id): id is string => !!id))] } : undefined
}
return profile.candidates.map(candidate => {
const first = leg(profile.serverId, candidate.jumpHostId, candidate.wanInterface, candidate.wanRemoteInterface)
const second = leg(candidate.jumpHostId, candidate.exitNodeId, candidate.downstreamInterface, candidate.downstreamRemoteInterface)
const reasons: string[] = []
if (profile.excludedCandidateIds.includes(candidate.id)) reasons.push("Путь исключён")
if (profile.pinnedCandidateId && profile.pinnedCandidateId !== candidate.id) reasons.push("Закреплён другой путь")
const legs = [first, second]
if (legs.some(value => !value)) reasons.push("Нет подтверждённых измерений обоих участков")
const pingKnown = legs.every(value => value?.pingMs !== null && value?.pingMs !== undefined && fresh(value.pingAt, profile.pingFreshnessSec, now))
const lossKnown = legs.every(value => value?.lossPct !== null && value?.lossPct !== undefined && fresh(value.pingAt, profile.pingFreshnessSec, now))
const speedKnown = legs.every(value => value?.dlMbps !== null && value?.dlMbps !== undefined && value.ulMbps !== null && fresh(value.bandwidthAt, profile.bandwidthFreshnessSec, now))
const pingMs = pingKnown ? first!.pingMs! + second!.pingMs! : null
const lossPct = lossKnown ? 100 * (1 - (1 - first!.lossPct! / 100) * (1 - second!.lossPct! / 100)) : null
const dlMbps = speedKnown ? Math.min(first!.dlMbps!, second!.dlMbps!) : null
const ulMbps = speedKnown ? Math.min(first!.ulMbps!, second!.ulMbps!) : null
if ((profile.weights.ping > 0 || profile.maxPingMs !== null) && pingMs === null) reasons.push("RTT неизвестен или устарел")
if ((profile.weights.loss > 0 || profile.maxLossPct !== null) && lossPct === null) reasons.push("Потери неизвестны или устарели")
if ((profile.weights.speed > 0 || profile.minBandwidthMbps !== null) && dlMbps === null) reasons.push("Bandwidth неизвестен или устарел")
if (pingMs !== null && profile.maxPingMs !== null && pingMs > profile.maxPingMs) reasons.push("Превышен RTT")
if (lossPct !== null && profile.maxLossPct !== null && lossPct > profile.maxLossPct) reasons.push("Превышены потери")
if (dlMbps !== null && ulMbps !== null && profile.minBandwidthMbps !== null && Math.min(dlMbps, ulMbps) < profile.minBandwidthMbps) reasons.push("Недостаточна пропускная способность")
const score = reasons.length ? null : Math.round((profile.weights.ping * Math.max(0, 100 - (pingMs ?? 0) * 0.6) + profile.weights.speed * Math.min(100, ((dlMbps ?? 0) + (ulMbps ?? 0)) / 18) + profile.weights.loss * (100 - (lossPct ?? 0))) / 100)
return { candidate, eligible: !reasons.length, score, reasons, pingMs, lossPct, dlMbps, ulMbps, observationIds: legs.flatMap(value => value?.sampleIds ?? []), observedPath: false as const }
}).sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || a.candidate.id.localeCompare(b.candidate.id))
}
export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string }
export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failureObservedAt?: string; failingCandidateId?: string }
const pathHealthFreshnessSec = 90
export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) {
const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) }
if (chargedSwitchesPerHour !== undefined && (!Number.isSafeInteger(chargedSwitchesPerHour) || chargedSwitchesPerHour < next.switches!.length || chargedSwitchesPerHour > 4096)) throw Error("Invalid common routing charge count")
const switchCount = chargedSwitchesPerHour ?? next.switches!.length
const best = ranking.find(candidate => candidate.eligible), current = ranking.find(candidate => candidate.candidate.id === currentCandidateId)
const currentHealth = health.find(value => value.candidateId === currentCandidateId && fresh(value.observedAt, pathHealthFreshnessSec, now))
if (currentHealth?.status === "failed" && currentCandidateId && !state.pausedReason) {
const observed = Date.parse(currentHealth.observedAt), previous = Date.parse(state.failureObservedAt ?? "")
const sameCandidate = state.failingCandidateId === currentCandidateId
if (state.failureKey === currentHealth.id) {
if (!sameCandidate || observed !== previous) return { state: next, switchTo: null, reason: "Ожидание новой проверки отказа" }
} else {
if (observed <= previous) return { state: next, switchTo: null, reason: "Ожидание новой проверки отказа" }
const continues = sameCandidate && fresh(state.failureObservedAt ?? null, pathHealthFreshnessSec, observed)
next.failureCount = continues ? (state.failureCount ?? 0) + 1 : 1
next.failureKey = currentHealth.id; next.failureObservedAt = currentHealth.observedAt; next.failingCandidateId = currentCandidateId
}
const reserve = ranking.find(value => value.eligible && value.candidate.id !== currentCandidateId && health.some(check => check.candidateId === value.candidate.id && check.status === "passed" && fresh(check.observedAt, pathHealthFreshnessSec, now)))
if ((next.failureCount ?? 0) < 3 || !reserve) return { state: next, switchTo: null, reason: "Failover ожидает три отдельных отказа и подтверждённый резерв" }
if (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет, включая failover" }
if (next.lastSwitchAt && now - Date.parse(next.lastSwitchAt) < profile.cooldownSec * 1000) return { state: next, switchTo: null, reason: "Failover ожидает окончания cooldown" }
return { state: { ...next, failureCount: 0 }, switchTo: reserve.candidate.id, reason: "Failover: три подтверждённых отказа и проверенный резерв" }
}
next.failureCount = 0; next.failureKey = undefined; next.failureObservedAt = undefined; next.failingCandidateId = undefined
if (state.pausedReason || !currentCandidateId || !best || best.candidate.id === currentCandidateId) return { state: { ...next, pendingCandidateId: undefined, consecutive: 0 }, switchTo: null, reason: "Нет подтверждённого улучшения или текущего пути" }
if (!current?.eligible || current.score === null) return { state: next, switchTo: null, reason: "Состояние текущего пути неизвестно; failover требует отдельных подтверждённых проверок отказа" }
const gain = best.score! - current.score
if (gain < Math.max(0, profile.switchThreshold - profile.hysteresis)) return { state: { ...next, consecutive: 0, pendingCandidateId: undefined }, switchTo: null, reason: "Сработал гистерезис" }
if (gain < profile.switchThreshold) return { state: next, switchTo: null, reason: "Порог улучшения не достигнут" }
const observationKey = [...best.observationIds, ...current.observationIds].sort().join(":")
if (next.pendingCandidateId !== undefined && next.pendingCandidateId !== best.candidate.id) {
next.pendingCandidateId = best.candidate.id
next.consecutive = 0
}
if (observationKey !== next.observationKey) {
next.consecutive = next.pendingCandidateId === best.candidate.id ? (next.consecutive ?? 0) + 1 : 1
next.pendingCandidateId = best.candidate.id; next.observationKey = observationKey
}
if (next.pendingCandidateId !== best.candidate.id || (next.consecutive ?? 0) < profile.confirmationSamples) return { state: next, switchTo: null, reason: "Ожидание новых измерений" }
if (next.lastSwitchAt && now - Date.parse(next.lastSwitchAt) < profile.cooldownSec * 1000) return { state: next, switchTo: null, reason: "Cooldown" }
if (switchCount >= profile.maxSwitchesPerHour) return { state: { ...next, pausedReason: "Лимит переключений" }, switchTo: null, reason: "Исчерпан часовой бюджет переключений" }
return { state: next, switchTo: best.candidate.id, reason: "Улучшение подтверждено свежими наблюдениями" }
}