Files
metapi/src/server/services/proxyChannelCoordinator.ts
T
BainilyandCita 9cf3dd1904 feat(proxy): improve channel failover stability and sticky session coordination / 优化代理通道故障切换与粘滞会话协调 (#268)
* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* feat(proxy): add sticky session channel coordination

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
2026-03-24 19:40:44 +08:00

296 lines
8.9 KiB
TypeScript

import { config } from '../config.js';
import {
getCredentialModeFromExtraConfig,
hasOauthProvider,
} from './accountExtraConfig.js';
type StickyEntry = {
channelId: number;
expiresAtMs: number;
};
type ActiveLeaseState = {
release: () => void;
};
type ChannelWaiter = {
cancelled: boolean;
resolve: (result: AcquireProxyChannelLeaseResult) => void;
timer: ReturnType<typeof setTimeout> | null;
};
type ChannelRuntimeState = {
activeLeaseIds: Set<number>;
queue: ChannelWaiter[];
};
export type ProxyChannelLease = {
channelId: number;
isActive(): boolean;
release(): void;
touch(): void;
};
export type AcquireProxyChannelLeaseResult =
| { status: 'acquired'; lease: ProxyChannelLease }
| { status: 'timeout'; waitMs: number };
const stickySessionBindings = new Map<string, StickyEntry>();
const channelRuntimeStates = new Map<number, ChannelRuntimeState>();
let nextLeaseId = 1;
function shouldUnrefTimer(timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>) {
if (typeof (timer as { unref?: () => void }).unref === 'function') {
(timer as { unref: () => void }).unref();
}
}
function cleanupExpiredStickyBindings(nowMs = Date.now()): void {
for (const [key, entry] of stickySessionBindings.entries()) {
if (entry.expiresAtMs <= nowMs) {
stickySessionBindings.delete(key);
}
}
}
function isSessionScopedChannel(extraConfig?: string | null): boolean {
return getCredentialModeFromExtraConfig(extraConfig) === 'session'
|| hasOauthProvider(extraConfig);
}
function getStickySessionTtlMs(): number {
return Math.max(30_000, Math.trunc(config.proxyStickySessionTtlMs || 0));
}
function getChannelLeaseTtlMs(): number {
return Math.max(5_000, Math.trunc(config.proxySessionChannelLeaseTtlMs || 0));
}
function getChannelLeaseKeepaliveMs(): number {
return Math.max(1_000, Math.trunc(config.proxySessionChannelLeaseKeepaliveMs || 0));
}
function getChannelQueueWaitMs(): number {
return Math.max(0, Math.trunc(config.proxySessionChannelQueueWaitMs || 0));
}
function getChannelConcurrencyLimit(extraConfig?: string | null): number {
if (!isSessionScopedChannel(extraConfig)) return 0;
return Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0));
}
function getOrCreateChannelRuntimeState(channelId: number): ChannelRuntimeState {
let state = channelRuntimeStates.get(channelId);
if (!state) {
state = {
activeLeaseIds: new Set<number>(),
queue: [],
};
channelRuntimeStates.set(channelId, state);
}
return state;
}
function maybeDeleteChannelRuntimeState(channelId: number): void {
const state = channelRuntimeStates.get(channelId);
if (!state) return;
if (state.activeLeaseIds.size <= 0 && state.queue.every((waiter) => waiter.cancelled)) {
channelRuntimeStates.delete(channelId);
}
}
function createNoopLease(channelId: number): ProxyChannelLease {
return {
channelId,
isActive: () => false,
release: () => {},
touch: () => {},
};
}
class ProxyChannelCoordinator {
buildStickySessionKey(input: {
clientKind?: string | null;
sessionId?: string | null;
requestedModel: string;
downstreamPath: string;
downstreamApiKeyId?: number | null;
}): string | null {
if (!config.proxyStickySessionEnabled) return null;
const sessionId = String(input.sessionId || '').trim();
if (!sessionId) return null;
const requestedModel = String(input.requestedModel || '').trim().toLowerCase();
if (!requestedModel) return null;
const downstreamPath = String(input.downstreamPath || '').trim().toLowerCase() || 'unknown';
const clientKind = String(input.clientKind || 'generic').trim().toLowerCase() || 'generic';
const owner = typeof input.downstreamApiKeyId === 'number' && Number.isFinite(input.downstreamApiKeyId)
? `key:${Math.trunc(input.downstreamApiKeyId)}`
: 'key:anonymous';
return [owner, clientKind, downstreamPath, requestedModel, sessionId].join('|');
}
getStickyChannelId(stickySessionKey?: string | null, nowMs = Date.now()): number | null {
cleanupExpiredStickyBindings(nowMs);
const normalizedKey = String(stickySessionKey || '').trim();
if (!normalizedKey) return null;
const entry = stickySessionBindings.get(normalizedKey);
if (!entry || entry.expiresAtMs <= nowMs) {
stickySessionBindings.delete(normalizedKey);
return null;
}
return entry.channelId;
}
bindStickyChannel(stickySessionKey: string | null | undefined, channelId: number, extraConfig?: string | null): void {
if (!config.proxyStickySessionEnabled) return;
if (!isSessionScopedChannel(extraConfig)) return;
const normalizedKey = String(stickySessionKey || '').trim();
if (!normalizedKey || !Number.isFinite(channelId) || channelId <= 0) return;
cleanupExpiredStickyBindings();
stickySessionBindings.set(normalizedKey, {
channelId: Math.trunc(channelId),
expiresAtMs: Date.now() + getStickySessionTtlMs(),
});
}
clearStickyChannel(stickySessionKey?: string | null, channelId?: number | null): void {
const normalizedKey = String(stickySessionKey || '').trim();
if (!normalizedKey) return;
const existing = stickySessionBindings.get(normalizedKey);
if (!existing) return;
if (typeof channelId === 'number' && Number.isFinite(channelId) && existing.channelId !== Math.trunc(channelId)) {
return;
}
stickySessionBindings.delete(normalizedKey);
}
async acquireChannelLease(input: {
channelId: number;
accountExtraConfig?: string | null;
}): Promise<AcquireProxyChannelLeaseResult> {
const channelId = Math.trunc(input.channelId || 0);
if (channelId <= 0) {
return {
status: 'acquired',
lease: createNoopLease(0),
};
}
const concurrencyLimit = getChannelConcurrencyLimit(input.accountExtraConfig);
if (concurrencyLimit <= 0) {
return {
status: 'acquired',
lease: createNoopLease(channelId),
};
}
const state = getOrCreateChannelRuntimeState(channelId);
if (state.activeLeaseIds.size < concurrencyLimit) {
return {
status: 'acquired',
lease: this.createTrackedLease(channelId, state),
};
}
const waitMs = getChannelQueueWaitMs();
if (waitMs <= 0) {
return {
status: 'timeout',
waitMs: 0,
};
}
return await new Promise<AcquireProxyChannelLeaseResult>((resolve) => {
const waiter: ChannelWaiter = {
cancelled: false,
resolve,
timer: null,
};
waiter.timer = setTimeout(() => {
waiter.cancelled = true;
waiter.timer = null;
maybeDeleteChannelRuntimeState(channelId);
resolve({
status: 'timeout',
waitMs,
});
}, waitMs);
shouldUnrefTimer(waiter.timer);
state.queue.push(waiter);
});
}
private createTrackedLease(channelId: number, state: ChannelRuntimeState): ProxyChannelLease {
const leaseId = nextLeaseId++;
state.activeLeaseIds.add(leaseId);
let released = false;
let expiryTimer: ReturnType<typeof setTimeout> | null = null;
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
const release = () => {
if (released) return;
released = true;
if (expiryTimer) clearTimeout(expiryTimer);
if (keepaliveTimer) clearInterval(keepaliveTimer);
state.activeLeaseIds.delete(leaseId);
this.drainQueue(channelId);
maybeDeleteChannelRuntimeState(channelId);
};
const touch = () => {
if (released) return;
if (expiryTimer) clearTimeout(expiryTimer);
expiryTimer = setTimeout(() => {
release();
}, getChannelLeaseTtlMs());
shouldUnrefTimer(expiryTimer);
};
touch();
const keepaliveMs = getChannelLeaseKeepaliveMs();
if (keepaliveMs > 0) {
keepaliveTimer = setInterval(() => {
touch();
}, keepaliveMs);
shouldUnrefTimer(keepaliveTimer);
}
return {
channelId,
isActive: () => !released,
release,
touch,
};
}
private drainQueue(channelId: number): void {
const state = channelRuntimeStates.get(channelId);
if (!state) return;
const concurrencyLimit = Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0));
while (state.activeLeaseIds.size < concurrencyLimit && state.queue.length > 0) {
const waiter = state.queue.shift();
if (!waiter || waiter.cancelled) continue;
if (waiter.timer) clearTimeout(waiter.timer);
waiter.timer = null;
waiter.resolve({
status: 'acquired',
lease: this.createTrackedLease(channelId, state),
});
}
}
}
export function resetProxyChannelCoordinatorState(): void {
stickySessionBindings.clear();
channelRuntimeStates.clear();
nextLeaseId = 1;
}
export function isProxyChannelSessionScoped(extraConfig?: string | null): boolean {
return isSessionScopedChannel(extraConfig);
}
export const proxyChannelCoordinator = new ProxyChannelCoordinator();