Skip to content

Commit 3ff7c33

Browse files
committed
Merge branch 'fix/quota-on-mongodb'
2 parents 42e2482 + 1056c75 commit 3ff7c33

File tree

2 files changed

+83
-47
lines changed

2 files changed

+83
-47
lines changed

services/api/src/budget.ts

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,5 @@
1-
import type IORedis from 'ioredis';
21
import type { SecurityPolicyDoc } from './db';
3-
import { createRedisClient } from './redis';
4-
5-
const DEFAULT_REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
6-
7-
let redisClient: IORedis | null = null;
8-
let redisHealthy = false;
9-
10-
function getRedis(): IORedis | null {
11-
if (!redisClient) {
12-
try {
13-
redisClient = createRedisClient(DEFAULT_REDIS_URL);
14-
redisClient.on('error', (err) => {
15-
// eslint-disable-next-line no-console
16-
console.error('budget: redis error', err);
17-
redisHealthy = false;
18-
});
19-
redisClient.on('connect', () => {
20-
redisHealthy = true;
21-
});
22-
} catch (err) {
23-
// eslint-disable-next-line no-console
24-
console.error('budget: failed to create redis client', err);
25-
redisHealthy = false;
26-
redisClient = null;
27-
}
28-
}
29-
return redisHealthy ? redisClient : null;
30-
}
2+
import { getOrgDailyBudgetsCollection } from './db';
313

324
function currentDateKeyUtc(): string {
335
const now = new Date();
@@ -39,30 +11,57 @@ function currentDateKeyUtc(): string {
3911
return `${y}${mm}${dd}`;
4012
}
4113

42-
async function incrementBudgetCounter(
43-
key: string,
14+
async function incrementMongoBudgetCounter(
15+
orgId: any,
16+
date: string,
17+
field: 'scan_count' | 'gpt_count',
4418
limit: number | null
4519
): Promise<boolean> {
4620
if (limit == null) {
47-
return true;
48-
}
49-
50-
const client = getRedis();
51-
if (!client) {
52-
// Redis unavailable: fail open, do not block scans or GPT
21+
// Unlimited budget
5322
return true;
5423
}
5524

5625
try {
57-
const count = await client.incr(key);
58-
if (count === 1) {
59-
// Keep key a bit longer than one day to tolerate clock skew
60-
await client.expire(key, 60 * 60 * 24 + 60 * 5);
26+
const col = await getOrgDailyBudgetsCollection();
27+
const now = new Date();
28+
29+
const result = (await col.findOneAndUpdate(
30+
{ org_id: orgId, date },
31+
{
32+
$setOnInsert: {
33+
org_id: orgId,
34+
date,
35+
created_at: now
36+
},
37+
$inc: { [field]: 1 },
38+
$set: { updated_at: now }
39+
},
40+
{
41+
upsert: true,
42+
returnDocument: 'after'
43+
}
44+
)) as any;
45+
46+
const doc = result && (result.value ?? result);
47+
if (!doc) {
48+
// Should not happen in normal operation; fail open to preserve
49+
// existing semantics where infra issues do not hard-block scans/GPT.
50+
// eslint-disable-next-line no-console
51+
console.error('budget: Mongo findOneAndUpdate returned no document', {
52+
orgId,
53+
date,
54+
field
55+
});
56+
return true;
6157
}
62-
return count <= limit;
58+
59+
const current = typeof doc[field] === 'number' ? doc[field] : 0;
60+
return current <= limit;
6361
} catch (err) {
62+
// Preserve fail-open semantics from the Redis implementation.
6463
// eslint-disable-next-line no-console
65-
console.error('budget: increment failed, failing open', err);
64+
console.error('budget: Mongo increment failed, failing open', err);
6665
return true;
6766
}
6867
}
@@ -74,8 +73,7 @@ export async function checkAndConsumeScanBudget(
7473
const limit =
7574
policy.max_scans_per_day == null ? null : policy.max_scans_per_day;
7675
const date = currentDateKeyUtc();
77-
const key = `budget:scan:${String(orgId)}:${date}`;
78-
return incrementBudgetCounter(key, limit);
76+
return incrementMongoBudgetCounter(orgId, date, 'scan_count', limit);
7977
}
8078

8179
export async function checkAndConsumeGptBudget(
@@ -85,6 +83,5 @@ export async function checkAndConsumeGptBudget(
8583
const limit =
8684
policy.max_gpt_calls_per_day == null ? null : policy.max_gpt_calls_per_day;
8785
const date = currentDateKeyUtc();
88-
const key = `budget:gpt:${String(orgId)}:${date}`;
89-
return incrementBudgetCounter(key, limit);
86+
return incrementMongoBudgetCounter(orgId, date, 'gpt_count', limit);
9087
}

services/api/src/db.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,24 @@ export interface SecurityPolicyDoc {
125125
created_at: Date;
126126
}
127127

128+
/**
129+
* Daily per-org budget counters for scans and GPT calls. One document per
130+
* (org_id, date) pair. Used to enforce SecurityPolicyDoc max_*_per_day
131+
* budgets without relying on Redis/Valkey.
132+
*/
133+
export interface OrgDailyBudgetDoc {
134+
_id?: any;
135+
org_id: any;
136+
// UTC date key in YYYYMMDD format (e.g. 20251210)
137+
date: string;
138+
// Number of scans consumed on this day
139+
scan_count: number;
140+
// Number of GPT calls consumed on this day
141+
gpt_count: number;
142+
created_at: Date;
143+
updated_at: Date;
144+
}
145+
128146
export interface PolicyOverrideDoc {
129147
_id?: any;
130148
org_id: any;
@@ -172,6 +190,27 @@ export async function getSecurityPoliciesCollection(): Promise<
172190
return col;
173191
}
174192

193+
export async function getOrgDailyBudgetsCollection(): Promise<
194+
Collection<OrgDailyBudgetDoc>
195+
> {
196+
const db = await getDb();
197+
const col = db.collection<OrgDailyBudgetDoc>('org_daily_budgets');
198+
199+
await col.createIndex(
200+
{ org_id: 1, date: 1 },
201+
{ unique: true, name: 'uniq_org_date' }
202+
);
203+
204+
// Optional TTL index to avoid unbounded growth. Documents will be removed
205+
// approximately 90 days after their last update.
206+
await col.createIndex(
207+
{ updated_at: 1 },
208+
{ name: 'ttl_org_daily_budgets', expireAfterSeconds: 60 * 60 * 24 * 90 }
209+
);
210+
211+
return col;
212+
}
213+
175214
export async function getPolicyOverridesCollection(): Promise<
176215
Collection<PolicyOverrideDoc>
177216
> {

0 commit comments

Comments
 (0)