Skip to content
Open
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
9 changes: 9 additions & 0 deletions apps/web/src/app/api/internal/usage/record/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
createPhaseTimer,
emitUsageRecordTiming,
isPrimaryPoolSaturated,
readPoolGauges,
shouldEmitUsageRecordTiming,
} from '@/lib/ai-gateway/usage-record-diagnostics';
Expand Down Expand Up @@ -46,6 +47,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
// the pre-check gets confirmed.
const timer = createPhaseTimer();
const poolBefore = readPoolGauges();
if (isPrimaryPoolSaturated(poolBefore)) {
// Do not join an unbounded pg-pool queue. A 503 proves no write started, so
// the caller can safely retry this delivery without creating a duplicate.
return NextResponse.json(
{ error: 'Usage record sink busy' },
{ status: 503, headers: { 'retry-after': '1' } }
);
}
let poolWaitingPeak = poolBefore.waiting;
const samplePool = () => {
const gauges = readPoolGauges();
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/lib/ai-gateway/usage-record-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { describe, expect, test } from '@jest/globals';
import {
createPhaseTimer,
describeDatabaseError,
isPrimaryPoolSaturated,
isUsageRowConflict,
readPoolGauges,
shouldEmitUsageRecordTiming,
Expand Down Expand Up @@ -252,6 +253,20 @@ describe('readPoolGauges', () => {
});
});

describe('isPrimaryPoolSaturated', () => {
test('rejects work when every configured connection is checked out', () => {
expect(isPrimaryPoolSaturated({ total: 10, idle: 0, waiting: 245 })).toBe(true);
});

test('accepts work when an idle connection is available', () => {
expect(isPrimaryPoolSaturated({ total: 10, idle: 1, waiting: 0 })).toBe(false);
});

test('accepts work when the pool can still open a connection', () => {
expect(isPrimaryPoolSaturated({ total: 9, idle: 0, waiting: 0 })).toBe(false);
});
});

describe('shouldEmitUsageRecordTiming', () => {
test('always emits at or above the slow threshold', () => {
expect(shouldEmitUsageRecordTiming(1_000, () => 1)).toBe(true);
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/ai-gateway/usage-record-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ export function readPoolGauges(): PoolGauges {
};
}

/**
* Whether a new request would have to queue for the primary pool.
*
* Fail open if the pool implementation does not expose its configured maximum.
*/
export function isPrimaryPoolSaturated(gauges: PoolGauges): boolean {
const max = poolMax();
return max !== null && gauges.idle === 0 && gauges.total >= max;
}

function eventLoopLagMs() {
return {
mean_ms: Math.round(eventLoopDelay.mean / 1e6),
Expand Down