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:
Bob.Hou
2026-09-21 18:07:41 -04:00
committed by GitHub
parent 66872b3271
commit 1853a61807
3 changed files with 109 additions and 7 deletions

View 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))

View File

@@ -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).

View File

@@ -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\)/);
});