fix(optimizer): ускорить точный выбор проб из большого пула

This commit is contained in:
Denozordec committed 2026-10-07 12:24:07 +07:00
1 parent c6dfa5267b
commit 52d8a05480
8 files changed
+306 -32

No files matched your search

+11
View File
@@ -170,3 +170,14 @@ Final1000WAN:134chars/86.5px высоты summary; WAN10:130chars/86.5px. В т
2026-10-04, UI/source расчёта не менялись. Child script принимает integer count20–1000, hard timeout10с, без API/DB/браузера/сети.7WAN×1000: equal1217.5ms (IDs p0..6), varied7967.5ms (p13..19), freshness не завершился за10с, процесс остановлен. Startup отдельно ~0.4s. Freshness создаёт1000 разных возрастов, будущие timestamps не используются. ESLint скрипта прошёл; корректность ID проверяется после успешного завершения.
Это оставшийся CPU риск, а не performance приёмка. В current DFS повторяются Date.parse/trim/score и оценка upper для ветвей, уже достигших максимум. Следующий12.3.66: score cache только для bounded WAN≤7 DFS с одним snapshot time и early stop при достижении node upper, preserving first tie/order/forced picks/greedy fallback. Проверить1200 legacy oracle fixtures, raw observations, profiles20/1000. Худшая комбинаторика и весь full-route product по-прежнему не ограничены.
## 12.3.66 — точный выбор проб из большого pool
2026-10-07. Score matrix вычисляется только при exact DFS≤7WAN/pool≥WAN, с единым временем ранжирования на назначение JH. Сырые наблюдения/даты не меняются, greedy fallback сохранён. Допустимый score-first greedy путь даёт только lower total, не готовое назначение. Пока DFS не нашёл реальный witness, равные верхние границы НЕ отсекаются; первое равное назначение остаётся в исходном pool order. Node stop требует реального bestAssign. Для каждого кандидата child upper выводится из topK максимумов текущего узла с исключением занятой пробы; уже проигрышные siblings не пересканируют весь pool. Forced pick сохраняется, даже если ветвь отсечена.
1230 seeded сравнений с независимым прежним DFS совпали по точным probe IDs, включая30 смешанных наборов7WAN.10 pure tests прошли: ties/partial matching, single snapshot при переходе часа, прежний greedy для8WAN, unknown/zero/disabled/raw snapshots.51 main browser и8 context прошли; TypeScript без incremental, targeted lint и production build успешны. Historical9 optimizer PNG восстановлены по точному списку.
Pure child CPU1000: equal1217.5→15.6ms,varied7967.5→14.5ms,freshness10с timeout→16.7ms; CPU20:11.5–11.7ms. Child startup отдельно~0.25s. Exact IDs проверены во всех6 профилях. Evidence docs/ux-ui/acceptance/optimizer-probes-cpu-2026-10-07.json содержит source SHA256, baseline HEAD и результаты. Браузерная live ветка7WAN/1000probes:219902bytes/7rows/222tableDOM/1018ms dev-ready; timestamps соответствуют p993..999, после refresh сохраняются,320px без overflow, API mutations отсутствуют. Это не production SLA/нагрузка PostgreSQL/RouterOS и не гарантия произвольного mixed pool: худшая экспоненциальная сложность DFS и full-route product остаются.
Следующий12.3.67: KPI captions JH/Exit (повторы/пустая площадка/unknown), затем separate full-route CPU profile. Общая main/context регрессия12.4.6/7 относится к прежним HEAD; этот delta принят профильными51+8. Внешняя12.5 не выполнена.
+1 -1
View File
@@ -1,6 +1,6 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-04: 12.3.65 baseline7WAN×1000pool сохранён: equal1218ms,varied7968ms,freshness timeout10с. Следующий12.3.66: кешировать score пары WAN/probe на одном времени снимка только в DFS≤7, безопасно завершать ветвь при достижении её upper bound; доказать exact IDs/ties и повторить20/1000 child profiles. Не запускать тяжёлый pool в UI. Внешняя12.5 остаётся.
Актуальное продолжение 2026-10-07: 12.3.66 завершён: DFS score cache/one-time snapshot/lower-bound/child-bound, exact1230 fixtures,10pure+51main+8context/build passed. CPU1000 equal15.6ms,varied14.5ms,freshness16.7ms вместо10с timeout. Следующий12.3.67: краткие KPI captions JH/Exit с честными площадками/unknown. Затем отдельный CPU-профиль full-route product; внешняя12.5 остаётся.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+1 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации
Актуальное продолжение 2026-10-04: 12.3.65 baseline7WAN×1000pool сохранён: equal1218ms,varied7968ms,freshness timeout10с. Следующий12.3.66: кешировать score пары WAN/probe на одном времени снимка только в DFS≤7, безопасно завершать ветвь при достижении её upper bound; доказать exact IDs/ties и повторить20/1000 child profiles. Не запускать тяжёлый pool в UI. Внешняя12.5 остаётся.
Актуальное продолжение 2026-10-07: 12.3.66 завершён: DFS score cache/one-time snapshot/lower-bound/child-bound, exact1230 fixtures,10pure+51main+8context/build passed. CPU1000 equal15.6ms,varied14.5ms,freshness16.7ms вместо10с timeout. Следующий12.3.67: краткие KPI captions JH/Exit с честными площадками/unknown. Затем отдельный CPU-профиль full-route product; внешняя12.5 остаётся.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
+11
View File
@@ -747,3 +747,14 @@ Final1000WAN:134chars/86.5px высоты summary; WAN10:130chars/86.5px. В т
2026-10-04, UI/source расчёта не менялись. Child script принимает integer count20–1000, hard timeout10с, без API/DB/браузера/сети.7WAN×1000: equal1217.5ms (IDs p0..6), varied7967.5ms (p13..19), freshness не завершился за10с, процесс остановлен. Startup отдельно ~0.4s. Freshness создаёт1000 разных возрастов, будущие timestamps не используются. ESLint скрипта прошёл; корректность ID проверяется после успешного завершения.
Это оставшийся CPU риск, а не performance приёмка. В current DFS повторяются Date.parse/trim/score и оценка upper для ветвей, уже достигших максимум. Следующий12.3.66: score cache только для bounded WAN≤7 DFS с одним snapshot time и early stop при достижении node upper, preserving first tie/order/forced picks/greedy fallback. Проверить1200 legacy oracle fixtures, raw observations, profiles20/1000. Худшая комбинаторика и весь full-route product по-прежнему не ограничены.
## 12.3.66 — точный выбор проб из большого pool
2026-10-07. Score matrix вычисляется только при exact DFS≤7WAN/pool≥WAN, с единым временем ранжирования на назначение JH. Сырые наблюдения/даты не меняются, greedy fallback сохранён. Допустимый score-first greedy путь даёт только lower total, не готовое назначение. Пока DFS не нашёл реальный witness, равные верхние границы НЕ отсекаются; первое равное назначение остаётся в исходном pool order. Node stop требует реального bestAssign. Для каждого кандидата child upper выводится из topK максимумов текущего узла с исключением занятой пробы; уже проигрышные siblings не пересканируют весь pool. Forced pick сохраняется, даже если ветвь отсечена.
1230 seeded сравнений с независимым прежним DFS совпали по точным probe IDs, включая30 смешанных наборов7WAN.10 pure tests прошли: ties/partial matching, single snapshot при переходе часа, прежний greedy для8WAN, unknown/zero/disabled/raw snapshots.51 main browser и8 context прошли; TypeScript без incremental, targeted lint и production build успешны. Historical9 optimizer PNG восстановлены по точному списку.
Pure child CPU1000: equal1217.5→15.6ms,varied7967.5→14.5ms,freshness10с timeout→16.7ms; CPU20:11.5–11.7ms. Child startup отдельно~0.25s. Exact IDs проверены во всех6 профилях. Evidence docs/ux-ui/acceptance/optimizer-probes-cpu-2026-10-07.json содержит source SHA256, baseline HEAD и результаты. Браузерная live ветка7WAN/1000probes:219902bytes/7rows/222tableDOM/1018ms dev-ready; timestamps соответствуют p993..999, после refresh сохраняются,320px без overflow, API mutations отсутствуют. Это не production SLA/нагрузка PostgreSQL/RouterOS и не гарантия произвольного mixed pool: худшая экспоненциальная сложность DFS и full-route product остаются.
Следующий12.3.67: KPI captions JH/Exit (повторы/пустая площадка/unknown), затем separate full-route CPU profile. Общая main/context регрессия12.4.6/7 относится к прежним HEAD; этот delta принят профильными51+8. Внешняя12.5 не выполнена.
@@ -0,0 +1,166 @@
{
"baseHead": "c6dfa5267b210b01fe72cbac7b310d3960a6a131",
"sourceSha256": "53273273c46f78cde2c82cff52b282dd19dcfc5c363f0b64c0b50cac44e20510",
"environment": "Pure calculation child process, no API/database/network",
"runs": [
{
"variant": "equal",
"wanCount": 7,
"probeCount": 20,
"timeoutMs": 10000,
"elapsedMs": 273,
"status": 0,
"timedOut": false,
"result": {
"elapsedMs": 11.667099999999976,
"legs": 7,
"probeIds": [
"p0",
"p1",
"p2",
"p3",
"p4",
"p5",
"p6"
]
},
"error": ""
},
{
"variant": "varied",
"wanCount": 7,
"probeCount": 20,
"timeoutMs": 10000,
"elapsedMs": 245,
"status": 0,
"timedOut": false,
"result": {
"elapsedMs": 11.477599999999995,
"legs": 7,
"probeIds": [
"p13",
"p14",
"p15",
"p16",
"p17",
"p18",
"p19"
]
},
"error": ""
},
{
"variant": "freshness",
"wanCount": 7,
"probeCount": 20,
"timeoutMs": 10000,
"elapsedMs": 275,
"status": 0,
"timedOut": false,
"result": {
"elapsedMs": 11.482100000000003,
"legs": 7,
"probeIds": [
"p13",
"p14",
"p15",
"p16",
"p17",
"p18",
"p19"
]
},
"error": ""
},
{
"variant": "equal",
"wanCount": 7,
"probeCount": 1000,
"timeoutMs": 10000,
"elapsedMs": 247,
"status": 0,
"timedOut": false,
"result": {
"elapsedMs": 15.580399999999997,
"legs": 7,
"probeIds": [
"p0",
"p1",
"p2",
"p3",
"p4",
"p5",
"p6"
]
},
"error": ""
},
{
"variant": "varied",
"wanCount": 7,
"probeCount": 1000,
"timeoutMs": 10000,
"elapsedMs": 244,
"status": 0,
"timedOut": false,
"result": {
"elapsedMs": 14.4709,
"legs": 7,
"probeIds": [
"p13",
"p14",
"p15",
"p16",
"p17",
"p18",
"p19"
]
},
"error": ""
},
{
"variant": "freshness",
"wanCount": 7,
"probeCount": 1000,
"timeoutMs": 10000,
"elapsedMs": 242,
"status": 0,
"timedOut": false,
"result": {
"elapsedMs": 16.685599999999994,
"legs": 7,
"probeIds": [
"p993",
"p994",
"p995",
"p996",
"p997",
"p998",
"p999"
]
},
"error": ""
}
],
"browserProfiles": {
"source": "Playwright stdout, live API fixture",
"passed": 51,
"contextPassed": 8,
"profiles": [
{
"poolCount": 20,
"probeBytes": 4382,
"rendered": 7,
"tableNodes": 222,
"readyMs": 1293
},
{
"poolCount": 1000,
"probeBytes": 219902,
"rendered": 7,
"tableNodes": 222,
"readyMs": 1018
}
]
}
}
+14 -2
View File
@@ -35,8 +35,8 @@ test("branch bound preserves legacy assignments for ties, incompatibility and pa
const now=Date.parse("2026-10-04T12:00:00Z");context.mock.method(Date,"now",()=>now)
let seed=917;const random=(n:number)=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return (seed>>>8)%n}
const names=["","ether1","ether2"," ETHER1 "]
for(let fixture=0;fixture<1200;fixture++){
const count=1+random(4),wans=Array.from({length:count},()=>names[1+random(names.length-1)])
for(let fixture=0;fixture<1230;fixture++){
const count=fixture<1200?1+random(4):7,wans=Array.from({length:count},()=>names[1+random(names.length-1)])
const pool=Array.from({length:count+random(3)},(_,i)=>({...probe(i,names[random(names.length)]),lastPingRttMs:random(2)?0:null,lastTxAvgMbps:random(2)?100:null,lastRxAvgMbps:random(2)?100:null,lastPingAt:random(3)?null:"2026-10-04T10:00:00Z"}))
assert.deepEqual(chosen(wans,pool),legacy(wans,pool,now),`fixture ${fixture}`)
}
@@ -51,3 +51,15 @@ test("early forced choice remains unchanged when a later WAN loses its compatibl
const wans=["ether1", "ether1"],pool=[probe(0,"ether1"),probe(1,"ether2")]
assert.deepEqual(chosen(wans,pool),["p0",null])
})
test("DFS uses one ranking time when the clock crosses an hour boundary",context=>{
const at=Date.parse('2026-10-07T12:00:00Z');let calls=0;context.mock.method(Date,'now',()=>at+calls++*3600000)
const pool=Array.from({length:3},(_,i)=>({...probe(i,''),lastPingAt:new Date(at-(2-i)*3600000).toISOString()}))
assert.deepEqual(chosen(['ether1','ether2'],pool),legacy(['ether1','ether2'],pool,at))
})
test("greedy fallback remains unchanged for eight WANs and a short pool",()=>{
const wans=Array.from({length:8},()=> 'ether1'),pool=[probe(0,'ether2'),probe(1,'ether1'),probe(2,''),probe(3,'')]
assert.deepEqual(chosen(wans,pool),['p1','p2','p3',null,null,null,null,null])
})
+62 -28
View File
@@ -307,7 +307,7 @@ export function buildLiveOptimizerData(
const probesForHome = speedProbes
.filter((p) => p.enabled !== false && p.srcServerId === home.id)
function scoreProbeForWanIface(p: RouteOptimizerSpeedProbe, wanIface: string): number {
function scoreProbeForWanIface(p: RouteOptimizerSpeedProbe, wanIface: string, at?: number): number {
const want = wanIface.trim().toLowerCase()
const got = String(p.srcInterface ?? "").trim().toLowerCase()
if (got && want && got !== want) return -1
@@ -317,7 +317,7 @@ export function buildLiveOptimizerData(
: (!want && got) ? 15
: 5
const freshness =
p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
p.lastPingAt ? Math.max(0, 20 - Math.floor(((at ?? Date.now()) - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
return ifaceScore
+ (p.lastPingRttMs != null ? 20 : 0)
+ (p.lastTxAvgMbps != null ? 10 : 0)
@@ -336,56 +336,90 @@ export function buildLiveOptimizerData(
let bestSum = -Infinity
let bestAssign: Map<string, RouteOptimizerSpeedProbe> | null = null
const wanList = [...home.wans]
const shouldSearch = wanList.length <= 7 && wanList.length <= pool.length
// Only the bounded-WAN exact search needs this matrix. A single time
// snapshot keeps rankings stable if a calculation crosses an hour boundary.
const scored = shouldSearch ? (() => {
const at = Date.now()
return pool.map(probe => ({ probe, values: wanList.map(wan => scoreProbeForWanIface(probe, wan.iface, at)) }))
})() : []
// A score-first greedy path is a valid leaf of the same forced-pick tree.
// Seed only its total, never its assignment: the original DFS must still
// visit the first equal leaf, so ties keep the original pool order.
if (shouldSearch && scored.every(candidate => candidate.values.every(Number.isFinite))) {
const used = new Set<string>()
let lower = 0
for (let i = 0; i < wanList.length; i++) {
let best: (typeof scored)[number] | undefined
let maximum = -1
for (const candidate of scored) {
if (!used.has(candidate.probe.id) && candidate.values[i]! > maximum) {
best = candidate
maximum = candidate.values[i]!
}
}
if (best) { used.add(best.probe.id); lower += maximum }
}
bestSum = lower
}
function dfs(i: number, used: Set<string>, cur: Map<string, RouteOptimizerSpeedProbe>, sum: number) {
if (i === wanList.length) {
if (sum > bestSum) {
if (sum > bestSum || (!bestAssign && sum === bestSum)) {
bestSum = sum
bestAssign = new Map(cur)
}
return
}
if (bestAssign) {
// Ignore competition for probes: independent maxima are an upper bound
// on every possible completion, including a WAN with no compatible probe.
let upper = sum
const candidateMax = new Map<string, number>()
for (let next = i; next < wanList.length; next++) {
let maximum = 0
for (const candidate of pool) {
if (used.has(candidate.id)) continue
const value = scoreProbeForWanIface(candidate, wanList[next]!.iface)
maximum = Math.max(maximum, value)
candidateMax.set(candidate.id, Math.max(candidateMax.get(candidate.id) ?? 0, value))
}
upper += maximum
// Independent WAN maxima and the highest distinct-probe maxima are
// both upper bounds: each completion picks at most one probe per WAN.
let upper = sum
const candidateMax = new Map<string, number>()
for (let next = i; next < wanList.length; next++) {
let maximum = 0
for (const candidate of scored) {
if (used.has(candidate.probe.id)) continue
const value = candidate.values[next]!
maximum = Math.max(maximum, value)
candidateMax.set(candidate.probe.id, Math.max(candidateMax.get(candidate.probe.id) ?? 0, value))
}
// A completion uses at most one distinct probe per remaining WAN.
// Its weight is bounded by the highest remaining probe maxima, even
// when those maxima cannot actually be assigned to different WANs.
const distinctUpper = sum + [...candidateMax.values()].sort((a, b) => b - a)
.slice(0, wanList.length - i).reduce((total, value) => total + value, 0)
upper = Math.min(upper, distinctUpper)
// Equal totals keep the first assignment in the original DFS order.
if (upper <= bestSum) return
upper += maximum
}
const remaining = wanList.length - i
const top = [...candidateMax.entries()].sort((a, b) => b[1] - a[1]).slice(0, remaining)
const topSum = top.reduce((total, [, value]) => total + value, 0)
const topById = new Map(top)
const distinctUpper = sum + topSum
upper = Math.min(upper, distinctUpper)
// Equal totals keep the first assignment in the original DFS order.
if (upper < bestSum || (bestAssign && upper <= bestSum)) return
const wan = wanList[i]!
let picked = false
for (const p of pool) {
for (const candidate of scored) {
const p = candidate.probe
if (used.has(p.id)) continue
const sc = scoreProbeForWanIface(p, wan.iface)
const sc = candidate.values[i]!
if (sc < 0) continue
picked = true
// Relax the child using this node's probe maxima. Removing a top
// probe leaves K-1 entries; otherwise remove the lowest of the top K.
// This avoids rescanning the whole pool for an already losing sibling.
const removed = topById.get(p.id) ?? (top.length === remaining ? top.at(-1)![1] : 0)
const childUpper = sum + sc + topSum - removed
if (childUpper < bestSum || (bestAssign && childUpper <= bestSum)) continue
used.add(p.id)
cur.set(wan.id, p)
dfs(i + 1, used, cur, sum + sc)
cur.delete(wan.id)
used.delete(p.id)
// No remaining sibling can improve a result reaching this node bound.
if (bestAssign && bestSum >= upper) return
}
if (!picked) dfs(i + 1, used, cur, sum)
}
if (wanList.length <= 7 && wanList.length <= pool.length) {
if (shouldSearch) {
dfs(0, new Set(), new Map(), 0)
}
+40
View File
@@ -0,0 +1,40 @@
import {test,expect} from "@playwright/test"
import {writeFileSync} from "node:fs"
import {optimizerServersSchema,optimizerProbesSchema} from "../../lib/route-optimizer-response"
for(const poolCount of [20,1000])test(`Оптимизатор7WAN: ранжирование ${poolCount} проб по свежести`,async({page},info)=>{
test.setTimeout(90000)
const at=Date.now()
const probes=optimizerProbesSchema.parse({probes:Array.from({length:poolCount},(_,i)=>({id:`p${i}`,srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",enabled:true,lastPingRttMs:10,lastPingLossPct:0,lastTxAvgMbps:100,lastRxAvgMbps:100,lastPingAt:new Date(at-(poolCount-i)*3600000).toISOString()}))})
const common = { host: "192.0.2.1", enabled: true, status: "online", latency: 10, country: "RU", site: "TEST", model: "CHR" }
const servers = optimizerServersSchema.parse([
{ ...common, id: 1, name: "Домашний роутер", type: "home-router", wanUplinks: Array.from({ length: 7 }, (_, i) => ({ id: `wan${i+1}`, name: `WAN${i+1}`, isp: "ISP", iface: `ether${i+1}`, ip: `198.51.100.${i+1}`, maxDl: 100, maxUl: 100 })) },
...Array.from({ length: 1 }, (_, i) => ({ ...common, id: i+2, name: `Jump ${i+1}`, type: "jump-host" })),
])
let reads=0
const writes: string[] = []
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if(path==="/api/uptime/speed-probes")reads++
if (r.request().method() !== "GET" && !path.endsWith("/preview")) writes.push(path)
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? servers : path === "/api/filters/rules" ? {rulesets:[]} : path === "/api/uptime/speed-probes" ? probes : path === "/api/ospf/catalog" ? { interfaces: [], instances: [], neighbors: [], bfdSessions: [], routes: [], failures: [] } : path.endsWith("/ospf/optimize/preview") ? { serverId: 1, serverName: "Домашний роутер", pingWeight: 60, interfacesTotal: 0, changedCount: 0, unchangedCount: 0, interfaces: [], changes: [] } : {} })
})
const started=Date.now();await page.goto("/route-optimizer?server=1&keep=1")
const table=page.getByRole("table").filter({hasText:"WAN-аплинк"}),rows=table.locator("tr[data-row-id]")
await expect(rows).toHaveCount(7)
for(let i=0;i<7;i++)await expect(rows.nth(i).locator("time")).toHaveAttribute("datetime",probes.probes[poolCount-7+i].lastPingAt!)
const profile={poolCount,wanCount:7,probeBytes:Buffer.byteLength(JSON.stringify(probes)),rendered:await rows.count(),tableNodes:await table.locator("*").count(),readyMs:Date.now()-started}
console.log("Optimizer probes",JSON.stringify(profile));writeFileSync(info.outputPath("probes-profile.json"),JSON.stringify(profile,null,2))
const before=reads;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>reads).toBeGreaterThan(before)
for(let i=0;i<7;i++)await expect(rows.nth(i).locator("time")).toHaveAttribute("datetime",probes.probes[poolCount-7+i].lastPingAt!)
await page.setViewportSize({width:320,height:1000});expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(320)
expect(writes).toEqual([])
})