Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/8906-quota-pool-combo-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(quota):** Deleting a quota pool now removes its scoped managed combos without racing in-flight pool mutations ([#8906](https://github.com/diegosouzapw/OmniRoute/pull/8906)) — thanks @xiaoyaner0201
8 changes: 3 additions & 5 deletions src/app/api/quota/pools/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,7 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise<
// helpers. Without the pre-update removal, a group/provider switch would leave
// orphan qtSd/ combos a quota key still sees. Guarded + non-fatal.
const combosNeedResync =
body !== null &&
typeof body === "object" &&
("connectionIds" in body || "groupId" in body);
body !== null && typeof body === "object" && ("connectionIds" in body || "groupId" in body);
if (combosNeedResync) {
try {
const { removeQuotaCombosForPool } = await import("@/lib/quota/quotaCombos");
Expand Down Expand Up @@ -106,7 +104,7 @@ export async function PATCH(request: Request, { params }: RouteParams): Promise<
id,
prevApiKeyIds,
nextApiKeyIds,
parsed.data.exclusive ?? false,
parsed.data.exclusive ?? false
);
}

Expand All @@ -132,7 +130,7 @@ export async function DELETE(request: Request, { params }: RouteParams): Promise

try {
const { id } = await params;
const existed = deletePool(id);
const existed = await deletePool(id);
if (!existed) {
return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 });
}
Expand Down
82 changes: 60 additions & 22 deletions src/lib/db/quotaPools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,32 @@
import { getDbInstance } from "./core";
// Phase B2: auto-mint/prune quotaShared-* combos when pool allocations change.
// Imported lazily (dynamic import in the hook) to avoid circular-dependency
// risk between db/ and quota/ modules. The import is fire-and-forget; combo
// failures never break pool CRUD.
// risk between db/ and quota/ modules. Sync hooks are fire-and-forget; deletion
// awaits its guarded cleanup while metadata is available. Combo failures never
// break pool CRUD.
const quotaComboMaintenance = new Map<string, Promise<unknown>>();
const deletingPools = new Set<string>();

/** Reset module-level state for test isolation. Call in test.after() hooks. */
export function resetQuotaPoolsModuleState(): void {
deletingPools.clear();
quotaComboMaintenance.clear();
}

function serializeQuotaComboMaintenance<T>(
poolId: string,
operation: () => Promise<T>
): Promise<T> {
const previous = quotaComboMaintenance.get(poolId);
const current = previous ? previous.catch(() => undefined).then(operation) : operation();
quotaComboMaintenance.set(poolId, current);
const cleanup = () => {
if (quotaComboMaintenance.get(poolId) === current) quotaComboMaintenance.delete(poolId);
};
void current.then(cleanup, cleanup);
return current;
}

async function syncQuotaCombosGuarded(poolId: string): Promise<void> {
try {
const { syncQuotaCombos } = await import("@/lib/quota/quotaCombos");
Expand Down Expand Up @@ -400,7 +424,7 @@ export function createPool(input: PoolCreate): QuotaPool {
);

// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
void syncQuotaCombosGuarded(id);
void serializeQuotaComboMaintenance(id, () => syncQuotaCombosGuarded(id));

return result;
}
Expand All @@ -412,6 +436,8 @@ export function createPool(input: PoolCreate): QuotaPool {
* connection_id (primary) is synced to connectionIds[0].
*/
export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
if (deletingPools.has(id)) return null;

const database = getDb();
const existing = database
.prepare<PoolRow>(
Expand Down Expand Up @@ -475,7 +501,7 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
const result = rowToPool(existing, getAllocations(id));

// Phase B2: fire-and-forget combo sync; failures are logged but never thrown.
void syncQuotaCombosGuarded(id);
void serializeQuotaComboMaintenance(id, () => syncQuotaCombosGuarded(id));

return result;
}
Expand All @@ -485,28 +511,38 @@ export function updatePool(id: string, input: PoolUpdate): QuotaPool | null {
* Also removes join rows in quota_pool_connections.
* Returns true if a row was deleted, false if not found.
*/
export function deletePool(id: string): boolean {
// Phase B2: remove quota combos BEFORE deleting the pool row so that
// removeQuotaCombosForPool can still resolve the pool name → slug.
void removeQuotaCombosGuarded(id);

const database = getDb();
const doDelete = database.transaction(() => {
database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id);
// Prune this pool id from every key's allowed_quotas JSON array.
database
.prepare(
`UPDATE api_keys SET allowed_quotas = COALESCE(
export async function deletePool(id: string): Promise<boolean> {
if (deletingPools.has(id)) return false;
const exists = getDb().prepare<{ id: string }>("SELECT id FROM quota_pools WHERE id = ?").get(id);
if (!exists) return false;
deletingPools.add(id);

const deletion = serializeQuotaComboMaintenance(id, async () => {
// Phase B2: remove quota combos BEFORE deleting the pool row so that
// removeQuotaCombosForPool can still resolve the pool name → slug.
await removeQuotaCombosGuarded(id);

const database = getDb();
const doDelete = database.transaction(() => {
database.prepare("DELETE FROM quota_pool_connections WHERE pool_id = ?").run(id);
// Prune this pool id from every key's allowed_quotas JSON array.
database
.prepare(
`UPDATE api_keys SET allowed_quotas = COALESCE(
(SELECT json_group_array(value) FROM json_each(api_keys.allowed_quotas) WHERE value != ?),
'[]')
WHERE allowed_quotas IS NOT NULL AND allowed_quotas != '[]'
AND EXISTS (SELECT 1 FROM json_each(api_keys.allowed_quotas) WHERE value = ?)`
)
.run(id, id);
return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id);
)
.run(id, id);
return database.prepare("DELETE FROM quota_pools WHERE id = ?").run(id);
});
const result = doDelete();
return result.changes > 0;
});
const result = doDelete();
return result.changes > 0;
const clearDeleting = () => deletingPools.delete(id);
void deletion.then(clearDeleting, clearDeleting);
return deletion;
}

/**
Expand Down Expand Up @@ -546,6 +582,8 @@ export function deletePool(id: string): boolean {
* Runs atomically: all pool writes are inside a single SQLite transaction.
*/
export function upsertAllocations(poolId: string, allocations: PoolAllocation[]): void {
if (deletingPools.has(poolId)) return;

const database = getDb();

// Normalize: when all weights are 0, distribute equally so the pool is usable
Expand Down Expand Up @@ -602,7 +640,7 @@ export function upsertAllocations(poolId: string, allocations: PoolAllocation[])

// Phase B2: fire-and-forget combo sync for the target pool only; failures are
// logged but never thrown. Sibling pools' combos are synced on their own lifecycle.
void syncQuotaCombosGuarded(poolId);
void serializeQuotaComboMaintenance(poolId, () => syncQuotaCombosGuarded(poolId));
}

/**
Expand Down
9 changes: 8 additions & 1 deletion src/lib/quota/quotaCombos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
for (const connId of pool.connectionIds) {
let connection: Record<string, unknown> | null = null;
try {
connection = (await getCachedProviderConnectionById(connId)) as Record<string, unknown> | null;
connection = (await getCachedProviderConnectionById(connId)) as Record<
string,
unknown
> | null;
} catch {
// Connection lookup failure — skip this connection.
continue;
Expand Down Expand Up @@ -202,6 +205,10 @@ export async function syncQuotaCombos(poolId: string): Promise<void> {
}));
try {
const existing = await getComboByName(comboName);
// A pool may be deleted while this fire-and-forget sync is awaiting combo
// lookups. Re-check immediately before the synchronous DB upsert so stale
// create/update work cannot recreate managed combos after delete cleanup.
if (!getPool(poolId)) return;
const payload = {
name: comboName,
models: steps,
Expand Down
Loading
Loading