mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
fix(startup): bound model-catalog sync fan-out at boot (#14113)
The first autoSync cycle launched every connection at once. A host with 112 connections then held 112 catalog JSON parses on a cold heap and died at the V8 cap. Cap in-flight fetches at 4 for the whole cycle, and wait 90s so boot can serve traffic and the 30s cleanup has already run. Signed-off-by: Minxi Hou <houminxi@gmail.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
This commit is contained in:
1
changelog.d/fixes/13975-model-sync-cycle-concurrency.md
Normal file
1
changelog.d/fixes/13975-model-sync-cycle-concurrency.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(startup):** the first model-catalog sync no longer fans out to every autoSync connection at once (112 concurrent catalog JSON parses were enough to OOM a 3 GiB V8 heap); the cycle now keeps at most 4 fetches in flight and waits 90s after boot so it does not overlap the 30s startup cleanup ([#13975](https://github.com/diegosouzapw/OmniRoute/issues/13975))
|
||||
@@ -15,6 +15,10 @@ import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLease
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
|
||||
export const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||
/** Cycle-wide in-flight cap. Heap cost is total catalog JSON, not one upstream. */
|
||||
export const MODEL_SYNC_CYCLE_CONCURRENCY = 4;
|
||||
/** First cycle after boot. Past cleanup's 30s so the two jobs do not overlap. */
|
||||
export const MODEL_SYNC_STARTUP_DELAY_MS = 90_000;
|
||||
const MODEL_SYNC_SETTING_KEY = "model_sync_last_run";
|
||||
const MODEL_SYNC_INTERNAL_AUTH_HEADER = "x-model-sync-internal-auth";
|
||||
|
||||
@@ -224,6 +228,29 @@ export async function syncConnectionModels(
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrencySettled<T, R>(
|
||||
values: T[],
|
||||
concurrency: number,
|
||||
mapper: (value: T) => Promise<R>
|
||||
): Promise<PromiseSettledResult<R>[]> {
|
||||
const results = new Array<PromiseSettledResult<R>>(values.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(Math.max(1, concurrency), values.length);
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < values.length) {
|
||||
const index = nextIndex++;
|
||||
try {
|
||||
const value = await mapper(values[index]);
|
||||
results[index] = { status: "fulfilled", value };
|
||||
} catch (reason) {
|
||||
results[index] = { status: "rejected", reason };
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one full model-sync cycle across all auto-sync connections.
|
||||
*/
|
||||
@@ -245,10 +272,10 @@ async function runSyncCycle(apiBaseUrl: string): Promise<void> {
|
||||
|
||||
console.log(`[ModelSync] Starting model sync cycle — ${connections.length} connection(s)`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
connections.map((conn) =>
|
||||
syncConnectionModels(conn.id, conn.name || conn.provider, apiBaseUrl)
|
||||
)
|
||||
const results = await mapWithConcurrencySettled(
|
||||
connections,
|
||||
MODEL_SYNC_CYCLE_CONCURRENCY,
|
||||
(conn) => syncConnectionModels(conn.id, conn.name || conn.provider, apiBaseUrl)
|
||||
);
|
||||
|
||||
const succeeded = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
|
||||
@@ -289,8 +316,11 @@ export function startModelSyncScheduler(
|
||||
|
||||
console.log(`[ModelSync] Scheduler started — interval: ${effectiveIntervalMs / 3_600_000}h`);
|
||||
|
||||
// Run immediately on startup (staggered by 5s to avoid startup congestion)
|
||||
const startupDelay = setTimeout(() => runSyncCycle(trustedApiBaseUrl), 5_000);
|
||||
// Serve traffic first; cleanup's first pass is +30s, so stay past that window.
|
||||
const startupDelay = setTimeout(
|
||||
() => runSyncCycle(trustedApiBaseUrl),
|
||||
MODEL_SYNC_STARTUP_DELAY_MS
|
||||
);
|
||||
startupDelay.unref?.();
|
||||
|
||||
// Codex-only: revalidate catalog only on first-start or app upgrade (not every boot).
|
||||
|
||||
@@ -366,7 +366,7 @@ test("modelSyncScheduler starts once, honors env interval and syncs only active
|
||||
scheduler.startModelSyncScheduler("http://127.0.0.1:8888", 9999);
|
||||
|
||||
assert.equal(timers.timeouts.length, 1);
|
||||
assert.equal(timers.timeouts[0].ms, 5000);
|
||||
assert.equal(timers.timeouts[0].ms, scheduler.MODEL_SYNC_STARTUP_DELAY_MS);
|
||||
assert.equal(timers.timeouts[0].unrefCalled, true);
|
||||
assert.equal(timers.intervals.length, 1);
|
||||
assert.equal(timers.intervals[0].ms, 6 * 60 * 60 * 1000);
|
||||
@@ -462,3 +462,74 @@ test("test 12: MODEL_SYNC_INTERVAL_HOURS still wins over default", () => {
|
||||
assert.match(source, /MODEL_SYNC_INTERVAL_HOURS/);
|
||||
assert.match(source, /envHours \* 60 \* 60 \* 1000/);
|
||||
});
|
||||
|
||||
async function flushMicrotasks(times = 20) {
|
||||
for (let i = 0; i < times; i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
test("runSyncCycle keeps in-flight catalog fetches at or below the cycle concurrency cap", async () => {
|
||||
const connectionCount = 12;
|
||||
const cycleCap = 4;
|
||||
|
||||
for (let i = 0; i < connectionCount; i++) {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: `Auto Sync ${i}`,
|
||||
apiKey: `gw-auth-placeholder-${i}`,
|
||||
providerSpecificData: { autoSync: true },
|
||||
});
|
||||
}
|
||||
|
||||
const timers = installTimerStubs();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
let releaseGate;
|
||||
const gate = new Promise((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await gate;
|
||||
inFlight -= 1;
|
||||
return new Response(JSON.stringify({ syncedModels: 1 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const scheduler = await loadScheduler("bounded-cycle-concurrency");
|
||||
assert.equal(scheduler.MODEL_SYNC_CYCLE_CONCURRENCY, cycleCap);
|
||||
scheduler.startModelSyncScheduler("http://127.0.0.1:7777", 1000);
|
||||
const cycle = timers.timeouts[0].fn();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.equal(peak, cycleCap);
|
||||
assert.equal(inFlight, cycleCap);
|
||||
|
||||
releaseGate();
|
||||
await cycle;
|
||||
assert.equal(peak, cycleCap);
|
||||
scheduler.stopModelSyncScheduler();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
timers.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("first model-sync cycle waits until after the 30s startup cleanup window", async () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), "src/shared/services/modelSyncScheduler.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const { MODEL_SYNC_STARTUP_DELAY_MS } = await loadScheduler("startup-delay-constant");
|
||||
assert.equal(MODEL_SYNC_STARTUP_DELAY_MS, 90_000);
|
||||
assert.match(source, /MODEL_SYNC_STARTUP_DELAY_MS/);
|
||||
assert.doesNotMatch(source, /setTimeout\(\(\) => runSyncCycle\(trustedApiBaseUrl\), 5_000\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user