From 691dae7079513d1fa0f3a47f7babd7c77e31765d Mon Sep 17 00:00:00 2001 From: Ray Doan Date: Sat, 11 Jul 2026 13:47:10 +0700 Subject: [PATCH] feat(proxy): implement latency-optimized proxy rotation strategy (#6798) * feat(proxy): implement latency-optimized proxy rotation strategy Reconstructed onto release/v3.8.47 to drop unrelated main-drift (deps/electron/proxy files belong to #6620, not this PR) and the direct CHANGELOG.md edit (fragments-first); the author's env/docs/i18n deltas were re-applied cleanly onto the release tip. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(proxy): add latency-rotation env var to .env.example PROXY_LATENCY_WINDOW_HOURS was referenced in src/lib/db/proxies.ts and documented in docs/reference/ENVIRONMENT.md, but missing from .env.example, tripping the env/docs sync gate. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * chore(changelog): re-sync CHANGELOG.md to release tip Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(proxy): extract latency-strategy helpers to keep frozen files under cap Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test(db-rules): expect 35 audited modules (proxyLatency joins INTENTIONALLY_INTERNAL) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .env.example | 5 + .../6798-latency-optimized-proxy-rotation.md | 1 + docs/reference/ENVIRONMENT.md | 1 + scripts/check/check-db-rules.mjs | 1 + .../components/ProxyRegistryManager.tsx | 35 ++-- .../components/proxyStrategyOptions.ts | 17 ++ src/i18n/messages/en.json | 3 +- src/lib/db/proxies.ts | 8 +- src/lib/db/proxies/types.ts | 3 +- src/lib/db/proxyLatency.ts | 55 ++++++ src/shared/validation/schemas/proxy.ts | 4 +- .../check-db-rules-classification.test.ts | 3 +- tests/unit/proxy-rotation-latency.test.ts | 157 ++++++++++++++++++ 13 files changed, 265 insertions(+), 28 deletions(-) create mode 100644 changelog.d/features/6798-latency-optimized-proxy-rotation.md create mode 100644 src/app/(dashboard)/dashboard/settings/components/proxyStrategyOptions.ts create mode 100644 src/lib/db/proxyLatency.ts create mode 100644 tests/unit/proxy-rotation-latency.test.ts diff --git a/.env.example b/.env.example index bcff1ff98b..546cd4dd69 100644 --- a/.env.example +++ b/.env.example @@ -1527,6 +1527,11 @@ APP_LOG_TO_FILE=true # Timeout for fast-fail health checks (ms). Default: 2000 # PROXY_FAST_FAIL_TIMEOUT_MS=2000 +# Time window (hours) for calculating the average latency of candidate proxies +# in the latency-optimized pool strategy. Default: 3 +# Used by: src/lib/db/proxies.ts +# PROXY_LATENCY_WINDOW_HOURS=3 + # Health check result cache TTL (ms). Default: 30000 (30s) # PROXY_HEALTH_CACHE_TTL_MS=30000 diff --git a/changelog.d/features/6798-latency-optimized-proxy-rotation.md b/changelog.d/features/6798-latency-optimized-proxy-rotation.md new file mode 100644 index 0000000000..e15583b2ec --- /dev/null +++ b/changelog.d/features/6798-latency-optimized-proxy-rotation.md @@ -0,0 +1 @@ +- **feat(proxy):** add a latency-optimized proxy rotation strategy that ranks pool entries by measured round-trip latency, extending the existing round-robin/random/sticky proxy-pool selection (#6798 — thanks @iamraydoan). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 927cb93973..6bad9d7969 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -835,6 +835,7 @@ Anthropic-compatible provider instead. | Variable | Default | Source File | Description | | ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PROXY_FAST_FAIL_TIMEOUT_MS` | `2000` | `src/lib/proxyHealth.ts` | Fast-fail health check timeout. | +| `PROXY_LATENCY_WINDOW_HOURS` | `3` | `src/lib/db/proxies.ts` | Time window (hours) for calculating the average latency of candidate proxies in the latency-optimized pool strategy. | | `PROXY_HEALTH_CACHE_TTL_MS` | `30000` | `src/lib/proxyHealth.ts` | Health check result cache TTL. | | `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | Cache TTL for failed proxy health probes. Keep this shorter than `PROXY_HEALTH_CACHE_TTL_MS` so transient proxy timeouts under high concurrency retry quickly without disabling fast-fail for truly dead proxies. | | `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. | diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index dae4b114f5..d91d7f52c0 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -64,6 +64,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts "providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421) "providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts + "proxyLatency", // intentionally-internal: imported directly by src/lib/db/proxies.ts (anti-barrel, #6798) "recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests "schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948) "secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização) diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 9e064eeeee..191be4cc9d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -8,6 +8,7 @@ import { ProxyStatusBadge } from "./ProxyStatusBadge"; import { ProxyHealthCell } from "./ProxyHealthCell"; import { ProxyBatchActions } from "./ProxyBatchActions"; import { ProxyCheckboxCell } from "./ProxyCheckboxCell"; +import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions"; type ProxyItem = { id: string; @@ -180,9 +181,7 @@ export default function ProxyRegistryManager() { const [poolOpen, setPoolOpen] = useState(false); const [poolScope, setPoolScope] = useState("provider"); const [poolScopeId, setPoolScopeId] = useState(""); - const [poolStrategy, setPoolStrategy] = useState<"round-robin" | "random" | "sticky">( - "round-robin" - ); + const [poolStrategy, setPoolStrategy] = useState("round-robin"); const [poolMembers, setPoolMembers] = useState([]); const [poolAddProxyId, setPoolAddProxyId] = useState(""); const [poolLoading, setPoolLoading] = useState(false); @@ -569,11 +568,7 @@ export default function ProxyRegistryManager() { ? payload.members : []; setPoolMembers(members.map((m) => m.proxyId)); - setPoolStrategy( - ["round-robin", "random", "sticky"].includes(payload?.strategy) - ? payload.strategy - : "round-robin" - ); + setPoolStrategy(isPoolStrategy(payload?.strategy) ? payload.strategy : "round-robin"); setPoolLoaded(true); } catch (e: any) { setError(e?.message || t("poolLoadFailed")); @@ -638,7 +633,7 @@ export default function ProxyRegistryManager() { } }; - const handlePoolStrategyChange = async (strategy: "round-robin" | "random" | "sticky") => { + const handlePoolStrategyChange = async (strategy: PoolStrategy) => { const previous = poolStrategy; setPoolStrategy(strategy); setError(null); @@ -1151,7 +1146,9 @@ export default function ProxyRegistryManager() { {poolScope !== "global" && (
- +
- +

{t("poolStrategyHint")}

diff --git a/src/app/(dashboard)/dashboard/settings/components/proxyStrategyOptions.ts b/src/app/(dashboard)/dashboard/settings/components/proxyStrategyOptions.ts new file mode 100644 index 0000000000..3f7a74e576 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxyStrategyOptions.ts @@ -0,0 +1,17 @@ +// Pool rotation strategy options shared by ProxyRegistryManager's pool strategy +// selector. Extracted so the union type has a single source of truth and the +//