Skip to content

Commit 8b506d2

Browse files
fix(config): coalesce rapid rebuildCache calls to eliminate O(n²) load (#391)
Closes #388. ## Problem Every config mutation (`saveProvider`, `saveAlias`, `deleteKey`, `setSetting`, etc.) immediately awaited `rebuildCache()`, which issues ~6–8 DB queries and reloads the `QuotaScheduler`. A bulk import of 20 aliases therefore triggered 20 full cache rebuilds and 20 scheduler reloads — `O(n²)` load. ## Solution Introduced lightweight write coalescing inside `ConfigService`: 1. **Pending-writes counter** — incremented on every mutation. 2. **Deferred `rebuildCache()`** — if pending writes exist, schedule a single rebuild for 100ms later instead of running immediately. The timer is reset on every new mutation so only the final call in a burst hits the DB. 3. **Promise deduplication** — an in-flight rebuild is tracked so duplicate callers wait on the same promise. 4. **Explicit `flush()`** — for tests or callers that need immediate consistency. ## Changes - `packages/backend/src/services/config-service.ts` - Add `pendingWrites`, `coalesceTimer`, `rebuildPromise`, `COALESCE_MS` - Convert mutation methods from `await this.rebuildCache()` to fire-and-forget scheduling - Rename original rebuild logic to `doRebuild()` - Add `executeRebuild()` for synchronous callers (`initialize`, `migrateLegacyTargetGroups`) - Add public `flush()` - `packages/backend/src/services/__tests__/config-service.test.ts` *(new)* - Tests that rapid mutations coalesce to a single rebuild - Tests that `flush()` forces an immediate rebuild - Tests that the timer eventually fires after mutations ## Backwards compatibility All existing callers continue to work — the mutation shape is unchanged (`async saveProvider(...)` still returns `Promise<void>`). The only observable difference is that bulk writes complete faster and the cache is eventually consistent within ~100ms. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 2ce119c commit 8b506d2

2 files changed

Lines changed: 222 additions & 17 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2+
3+
vi.mock('../quota/quota-scheduler', () => ({
4+
QuotaScheduler: {
5+
getInstance: vi.fn(() => ({
6+
getCheckerIds: vi.fn(() => []),
7+
reload: vi.fn(),
8+
})),
9+
},
10+
}));
11+
12+
import { ConfigService } from '../config-service';
13+
14+
function createMockRepo() {
15+
return {
16+
saveProvider: vi.fn(),
17+
deleteProvider: vi.fn(),
18+
saveAlias: vi.fn(),
19+
deleteAlias: vi.fn(),
20+
deleteAllAliases: vi.fn(() => Promise.resolve(0)),
21+
saveKey: vi.fn(),
22+
deleteKey: vi.fn(),
23+
saveUserQuota: vi.fn(),
24+
deleteUserQuota: vi.fn(),
25+
saveMcpServer: vi.fn(),
26+
deleteMcpServer: vi.fn(),
27+
setSetting: vi.fn(),
28+
setSettingsBulk: vi.fn(),
29+
getAllProviders: vi.fn(() => Promise.resolve({})),
30+
getAllAliases: vi.fn(() => Promise.resolve({})),
31+
getAllKeys: vi.fn(() => Promise.resolve({})),
32+
getAllUserQuotas: vi.fn(() => Promise.resolve({})),
33+
getAllMcpServers: vi.fn(() => Promise.resolve({})),
34+
getFailoverPolicy: vi.fn(() => Promise.resolve({ enabled: false })),
35+
getCooldownPolicy: vi.fn(() => Promise.resolve({ enabled: false })),
36+
getAllSettings: vi.fn(() => Promise.resolve({})),
37+
};
38+
}
39+
40+
describe('ConfigService write coalescing', () => {
41+
let service: ConfigService;
42+
let mockRepo: ReturnType<typeof createMockRepo>;
43+
let rebuildCount: number;
44+
45+
beforeEach(() => {
46+
ConfigService.resetInstance();
47+
rebuildCount = 0;
48+
mockRepo = createMockRepo();
49+
50+
service = new ConfigService(mockRepo as any);
51+
52+
// Spy on doRebuild to count actual database-level rebuilds
53+
const original = (service as any).doRebuild.bind(service);
54+
(service as any).doRebuild = async () => {
55+
rebuildCount++;
56+
// Still call original so the cache is populated and flush() works
57+
await original();
58+
};
59+
});
60+
61+
afterEach(() => {
62+
ConfigService.resetInstance();
63+
vi.useRealTimers();
64+
});
65+
66+
it('coalesces multiple rapid mutations into a single rebuild', async () => {
67+
vi.useFakeTimers();
68+
69+
await service.saveProvider('p1', {} as any);
70+
await service.saveProvider('p2', {} as any);
71+
await service.saveAlias('a1', {} as any);
72+
73+
expect(rebuildCount).toBe(0);
74+
75+
await vi.advanceTimersByTimeAsync(150);
76+
77+
expect(rebuildCount).toBe(1);
78+
79+
vi.useRealTimers();
80+
});
81+
82+
it('flush forces immediate rebuild', async () => {
83+
vi.useFakeTimers();
84+
85+
await service.saveProvider('p1', {} as any);
86+
expect(rebuildCount).toBe(0);
87+
88+
await service.flush();
89+
expect(rebuildCount).toBe(1);
90+
91+
vi.useRealTimers();
92+
});
93+
94+
it('timer eventually fires and rebuilds after mutations', async () => {
95+
vi.useFakeTimers();
96+
97+
await service.saveProvider('p1', {} as any);
98+
expect(rebuildCount).toBe(0);
99+
100+
await vi.advanceTimersByTimeAsync(150);
101+
expect(rebuildCount).toBe(1);
102+
103+
// A subsequent mutation triggers a new rebuild cycle
104+
await service.saveAlias('a1', {} as any);
105+
expect(rebuildCount).toBe(1);
106+
107+
await vi.advanceTimersByTimeAsync(150);
108+
expect(rebuildCount).toBe(2);
109+
110+
vi.useRealTimers();
111+
});
112+
});

packages/backend/src/services/config-service.ts

Lines changed: 110 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ export class ConfigService {
2929
private cache: PlexusConfig | null = null;
3030
private repo: ConfigRepository;
3131

32+
/** Number of writes issued since the last rebuild; used for coalescing. */
33+
private pendingWrites = 0;
34+
/** Active timer for a deferred rebuild. */
35+
private coalesceTimer: ReturnType<typeof setTimeout> | null = null;
36+
/** Promise for an in-flight rebuild so parallel callers can wait on it. */
37+
private rebuildPromise: Promise<void> | null = null;
38+
/** Delay (ms) before a coalesced rebuild fires. */
39+
private readonly COALESCE_MS = 100;
40+
3241
constructor(repo?: ConfigRepository) {
3342
this.repo = repo ?? new ConfigRepository();
3443
}
@@ -63,7 +72,7 @@ export class ConfigService {
6372
* Must be called once during startup, after DB is initialized.
6473
*/
6574
async initialize(): Promise<void> {
66-
await this.rebuildCache();
75+
await this.executeRebuild();
6776
logger.debug('ConfigService initialized from database');
6877
}
6978

@@ -80,7 +89,7 @@ export class ConfigService {
8089
logger.info(
8190
`Migrated ${migrated.length} legacy aliases to target groups: ${migrated.join(', ')}`
8291
);
83-
await this.rebuildCache();
92+
await this.executeRebuild();
8493
}
8594
return migrated;
8695
}
@@ -107,78 +116,91 @@ export class ConfigService {
107116

108117
async saveProvider(slug: string, config: ProviderConfig): Promise<void> {
109118
await this.repo.saveProvider(slug, config);
110-
await this.rebuildCache();
119+
this.pendingWrites++;
120+
this.rebuildCache();
111121
}
112122

113123
async deleteProvider(slug: string, cascade: boolean = true): Promise<void> {
114124
await this.repo.deleteProvider(slug, cascade);
115-
await this.rebuildCache();
125+
this.pendingWrites++;
126+
this.rebuildCache();
116127
}
117128

118129
// ─── Alias CRUD ──────────────────────────────────────────────────
119130

120131
async saveAlias(slug: string, config: ModelConfig): Promise<void> {
121132
await this.repo.saveAlias(slug, config);
122-
await this.rebuildCache();
133+
this.pendingWrites++;
134+
this.rebuildCache();
123135
}
124136

125137
async deleteAlias(slug: string): Promise<void> {
126138
await this.repo.deleteAlias(slug);
127-
await this.rebuildCache();
139+
this.pendingWrites++;
140+
this.rebuildCache();
128141
}
129142

130143
async deleteAllAliases(): Promise<number> {
131144
const count = await this.repo.deleteAllAliases();
132-
await this.rebuildCache();
145+
this.pendingWrites++;
146+
this.rebuildCache();
133147
return count;
134148
}
135149

136150
// ─── Key CRUD ────────────────────────────────────────────────────
137151

138152
async saveKey(name: string, config: KeyConfig): Promise<void> {
139153
await this.repo.saveKey(name, config);
140-
await this.rebuildCache();
154+
this.pendingWrites++;
155+
this.rebuildCache();
141156
}
142157

143158
async deleteKey(name: string): Promise<void> {
144159
await this.repo.deleteKey(name);
145-
await this.rebuildCache();
160+
this.pendingWrites++;
161+
this.rebuildCache();
146162
}
147163

148164
// ─── User Quota CRUD ─────────────────────────────────────────────
149165

150166
async saveUserQuota(name: string, quota: QuotaDefinition): Promise<void> {
151167
await this.repo.saveUserQuota(name, quota);
152-
await this.rebuildCache();
168+
this.pendingWrites++;
169+
this.rebuildCache();
153170
}
154171

155172
async deleteUserQuota(name: string): Promise<void> {
156173
await this.repo.deleteUserQuota(name);
157-
await this.rebuildCache();
174+
this.pendingWrites++;
175+
this.rebuildCache();
158176
}
159177

160178
// ─── MCP Server CRUD ─────────────────────────────────────────────
161179

162180
async saveMcpServer(name: string, config: McpServerConfig): Promise<void> {
163181
await this.repo.saveMcpServer(name, config);
164-
await this.rebuildCache();
182+
this.pendingWrites++;
183+
this.rebuildCache();
165184
}
166185

167186
async deleteMcpServer(name: string): Promise<void> {
168187
await this.repo.deleteMcpServer(name);
169-
await this.rebuildCache();
188+
this.pendingWrites++;
189+
this.rebuildCache();
170190
}
171191

172192
// ─── Settings ─────────────────────────────────────────────────────
173193

174194
async setSetting(key: string, value: unknown): Promise<void> {
175195
await this.repo.setSetting(key, value);
176-
await this.rebuildCache();
196+
this.pendingWrites++;
197+
this.rebuildCache();
177198
}
178199

179200
async setSettingsBulk(entries: Record<string, unknown>): Promise<void> {
180201
await this.repo.setSettingsBulk(entries);
181-
await this.rebuildCache();
202+
this.pendingWrites++;
203+
this.rebuildCache();
182204
}
183205

184206
async getSetting<T>(key: string, defaultValue: T): Promise<T> {
@@ -268,12 +290,83 @@ export class ConfigService {
268290
};
269291
}
270292

293+
// ─── Write Coalescing & Cache Flush ─────────────────────────────
294+
295+
/**
296+
* Force an immediate, synchronous cache rebuild.
297+
* Cancels any pending coalesced rebuild and waits for an in-flight one.
298+
* Useful in tests or operations that need immediate consistency.
299+
*/
300+
async flush(): Promise<void> {
301+
if (this.coalesceTimer) {
302+
clearTimeout(this.coalesceTimer);
303+
this.coalesceTimer = null;
304+
}
305+
if (this.rebuildPromise) {
306+
await this.rebuildPromise;
307+
}
308+
this.pendingWrites = 0;
309+
await this.executeRebuild();
310+
}
311+
271312
// ─── Internal ────────────────────────────────────────────────────
272313

273314
/**
274-
* Rebuild the in-memory cache from the database.
315+
* Schedule a cache rebuild, coalescing rapid successive calls.
316+
* If pending writes are present the rebuild is deferred; only the
317+
* final call in a burst actually hits the database.
318+
*/
319+
private rebuildCache(): void {
320+
if (this.coalesceTimer) {
321+
clearTimeout(this.coalesceTimer);
322+
this.coalesceTimer = null;
323+
}
324+
325+
if (this.pendingWrites > 0) {
326+
this.coalesceTimer = setTimeout(() => {
327+
this.pendingWrites = 0;
328+
this.rebuildCache();
329+
}, this.COALESCE_MS);
330+
return;
331+
}
332+
333+
if (this.rebuildPromise) {
334+
this.coalesceTimer = setTimeout(() => this.rebuildCache(), this.COALESCE_MS);
335+
return;
336+
}
337+
338+
const promise = this.executeRebuild();
339+
this.rebuildPromise = promise;
340+
promise.finally(() => {
341+
if (this.rebuildPromise === promise) {
342+
this.rebuildPromise = null;
343+
}
344+
});
345+
}
346+
347+
/**
348+
* Execute the actual cache rebuild. Guarantees that only one rebuild
349+
* runs concurrently; duplicate callers receive the in-flight promise.
350+
*/
351+
private async executeRebuild(): Promise<void> {
352+
if (this.rebuildPromise) {
353+
return this.rebuildPromise;
354+
}
355+
const promise = this.doRebuild();
356+
this.rebuildPromise = promise;
357+
try {
358+
await promise;
359+
} finally {
360+
if (this.rebuildPromise === promise) {
361+
this.rebuildPromise = null;
362+
}
363+
}
364+
}
365+
366+
/**
367+
* Core rebuild logic — loads the full config graph from the database.
275368
*/
276-
private async rebuildCache(): Promise<void> {
369+
private async doRebuild(): Promise<void> {
277370
const providers = await this.repo.getAllProviders();
278371
const models = await this.repo.getAllAliases();
279372
const keys = await this.repo.getAllKeys();

0 commit comments

Comments
 (0)