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 src/events/event-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const DomainEventName = {
BudgetUpdated: 'budget.updated',
BudgetAllocated: 'budget.allocated',
BudgetConsumed: 'budget.consumed',
BudgetReleased: 'budget.released',
BudgetExceeded: 'budget.exceeded',
BudgetWarning: 'budget.warning',

Expand Down
15 changes: 13 additions & 2 deletions src/modules/budgets/budget.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { Module } from '@nestjs/common';
import { BudgetController } from './budget.controller';
import { BudgetService } from './budget.service';
import { BudgetRepository } from './budget.repository';
import { BudgetReservationService } from './services/budget-reservation.service';
import { PolicyEvaluatorService } from './services/policy-evaluator.service';
import { RedisLock } from '../../common/locks/redis-lock.util';
import { RollingWindowBudgetService } from './services/rolling-window-budget.service';

/**
* Budget module. Exports the service so the transactions pipeline can enforce
* spend limits (assertWithinBudget) and record realised spend (consume).
* spend limits (reserve) and record realised spend (consume / release).
* BudgetReservationService provides the distributed-lock + atomic reservation
* that prevents concurrent agent requests from overspending a budget.
* Also provides the PolicyEvaluatorService for combined policy + budget
* evaluation, and RollingWindowBudgetService for configurable rolling-window
* spend checks (distinct from the fixed-period Budget counter).
Expand All @@ -16,7 +20,14 @@ import { RollingWindowBudgetService } from './services/rolling-window-budget.ser
*/
@Module({
controllers: [BudgetController],
providers: [BudgetService, BudgetRepository, PolicyEvaluatorService, RollingWindowBudgetService],
providers: [
BudgetService,
BudgetRepository,
BudgetReservationService,
RedisLock,
PolicyEvaluatorService,
RollingWindowBudgetService,
],
exports: [BudgetService, PolicyEvaluatorService, RollingWindowBudgetService],
})
export class BudgetModule {}
25 changes: 6 additions & 19 deletions src/modules/budgets/budget.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,19 @@ export class BudgetRepository {
return this.prisma.budget.update({ where: { id }, data });
}

/** Atomically increments `spent` by `amount` (positive) — used to persist a reservation. */
incrementSpent(id: string, amount: Prisma.Decimal): Promise<Budget> {
return this.prisma.budget.update({
where: { id },
data: { spent: { increment: amount } },
});
}

async reserveBudget(organizationId: string, id: string, amount: Prisma.Decimal): Promise<Budget> {
return this.prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Budget[]>`SELECT * FROM "budgets" WHERE id = ${id} AND "organizationId" = ${organizationId} FOR UPDATE`;
if (!rows || rows.length === 0) {
throw new Error('NotFoundException');
}

const budget = rows[0];
const spentAfter = new Prisma.Decimal(budget.spent).plus(amount);
const limit = new Prisma.Decimal(budget.limitAmount);

if (spentAfter.greaterThan(limit)) {
throw new Error('ConflictException: BudgetExceeded');
}

return tx.budget.update({
where: { id },
data: { spent: spentAfter },
});
/** Atomically decrements `spent` by `amount` (positive) — used to release a reservation. */
decrementSpent(id: string, amount: Prisma.Decimal): Promise<Budget> {
return this.prisma.budget.update({
where: { id },
data: { spent: { decrement: amount } },
});
}

Expand Down
163 changes: 25 additions & 138 deletions src/modules/budgets/budget.service-lock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Budget, Prisma } from '@prisma/client';
import { BudgetService } from './budget.service';
import { BudgetRepository } from './budget.repository';
import { BudgetReservationService } from './services/budget-reservation.service';
import { RedisLock } from '../../common/locks/redis-lock.util';
import { EventBusService } from '../../events/event-bus.service';
import { BudgetExceededException } from '../../common/exceptions/domain.exception';
Expand Down Expand Up @@ -69,7 +70,8 @@ describe('BudgetService — distributed locking', () => {
emit: vi.fn().mockResolvedValue(undefined),
} as unknown as EventBusService;

service = new BudgetService(repository, eventBus, redisLock);
const reservation = { reserve: vi.fn() } as unknown as BudgetReservationService;
service = new BudgetService(repository, eventBus, reservation, redisLock);
});

// ── allocate ──
Expand Down Expand Up @@ -180,172 +182,57 @@ describe('BudgetService — distributed locking', () => {
// ── consume ──

describe('consume', () => {
it('acquires a lock on the budget consume key during spend increment', async () => {
const budget = mockBudget({ id: 'budget-1', spent: 0, limitAmount: 10000 });
vi.mocked(repository.incrementSpent).mockResolvedValue({ ...budget, spent: new Decimal(100) } as Budget);
it('emits budget.consumed after settlement (no lock, no increment)', async () => {
const budget = mockBudget({ id: 'budget-1', spent: 100, limitAmount: 10000 });
vi.mocked(repository.findById).mockResolvedValue(budget);

await service.consume('org-1', 'budget-1', 100);

expect(redisLock.withLock).toHaveBeenCalledWith(
'budget:consume:budget-1',
expect.any(Function),
expect(eventBus.emit).toHaveBeenCalledWith(
'budget.consumed',
expect.objectContaining({ budgetId: 'budget-1', amount: 100 }),
expect.any(Object),
);
});

it('serializes concurrent consume calls for the same budget', async () => {
const budget = mockBudget({ id: 'budget-1', spent: 0, limitAmount: 10000 });
let callCount = 0;

vi.mocked(repository.incrementSpent).mockImplementation(async () => {
callCount++;
return { ...budget, spent: new Decimal(callCount * 100) } as Budget;
});

const promises = [
service.consume('org-1', 'budget-1', 100),
service.consume('org-1', 'budget-1', 200),
service.consume('org-1', 'budget-1', 50),
];

await Promise.all(promises);

expect(redisLock.withLock).toHaveBeenCalledTimes(3);
expect(repository.incrementSpent).toHaveBeenCalledTimes(3);
});

it('uses separate lock keys for different budgets', async () => {
const budget1 = mockBudget({ id: 'b1', spent: 0 });
const budget2 = mockBudget({ id: 'b2', spent: 0 });

vi.mocked(repository.incrementSpent)
.mockResolvedValueOnce({ ...budget1, spent: new Decimal(100) } as Budget)
.mockResolvedValueOnce({ ...budget2, spent: new Decimal(200) } as Budget);

await service.consume('org-1', 'b1', 100);
await service.consume('org-1', 'b2', 200);

expect(redisLock.withLock).toHaveBeenNthCalledWith(1, 'budget:consume:b1', expect.any(Function));
expect(redisLock.withLock).toHaveBeenNthCalledWith(2, 'budget:consume:b2', expect.any(Function));
});

it('emits budget consumed and warning events after locked spend increment', async () => {
it('emits budget.warning at 80% utilisation', async () => {
const budget = mockBudget({ id: 'budget-1', spent: 7500, limitAmount: 10000 });
vi.mocked(repository.incrementSpent).mockResolvedValue({ ...budget, spent: new Decimal(8500) } as Budget);
vi.mocked(repository.findById).mockResolvedValue({ ...budget, spent: new Decimal(8500) } as Budget);

await service.consume('org-1', 'budget-1', 1000);

expect(eventBus.emit).toHaveBeenCalledWith(
'budget.consumed',
expect.objectContaining({ budgetId: 'budget-1', amount: 1000 }),
expect.any(Object),
);
// 85% utilisation → should also emit budget warning
expect(eventBus.emit).toHaveBeenCalledWith(
'budget.warning',
expect.objectContaining({ budgetId: 'budget-1', utilisation: '0.8500' }),
expect.any(Object),
);
});
});

// ── assertWithinBudget ──

describe('assertWithinBudget', () => {
it('acquires a lock on the budget check key during the pre-flight check', async () => {
const budget = mockBudget({ id: 'budget-1', spent: 1000, limitAmount: 10000 });
it('does not acquire a lock (settlement is read-only)', async () => {
const budget = mockBudget({ id: 'budget-1', spent: 0, limitAmount: 10000 });
vi.mocked(repository.findById).mockResolvedValue(budget);

await service.assertWithinBudget('org-1', 'budget-1', 500);
await service.consume('org-1', 'budget-1', 100);

expect(redisLock.withLock).toHaveBeenCalledWith(
'budget:check:budget-1',
expect.any(Function),
);
expect(redisLock.withLock).not.toHaveBeenCalled();
});
});

it('throws BudgetExceededException within the lock when the spend would exceed the limit', async () => {
const budget = mockBudget({ id: 'budget-1', spent: 9500, limitAmount: 10000 });
vi.mocked(repository.findById).mockResolvedValue(budget);

await expect(service.assertWithinBudget('org-1', 'budget-1', 1000)).rejects.toThrow(
BudgetExceededException,
);
// ── reserve ──

// Lock should still have been called (no leak)
expect(redisLock.withLock).toHaveBeenCalledWith(
'budget:check:budget-1',
expect.any(Function),
);
});

it('releases the lock even when BudgetExceededException is thrown', async () => {
const budget = mockBudget({ id: 'budget-1', spent: 9500, limitAmount: 10000 });
describe('reserve', () => {
it('delegates to BudgetReservationService.reserve after existence check', async () => {
const budget = mockBudget({ id: 'budget-1', limitAmount: 10000, spent: 0 });
vi.mocked(repository.findById).mockResolvedValue(budget);
const reservationService = { reserve: vi.fn().mockResolvedValue(budget) } as unknown as BudgetReservationService;
const svc = new BudgetService(repository, eventBus, reservationService, redisLock);

let lockReleased = false;
vi.mocked(redisLock.withLock).mockImplementation(async (_key: unknown, fn: () => Promise<unknown>) => {
try {
return await fn();
} finally {
lockReleased = true;
}
});
await svc.reserve('org-1', 'budget-1', 500);

await expect(service.assertWithinBudget('org-1', 'budget-1', 1000)).rejects.toThrow();

expect(lockReleased).toBe(true);
expect(reservationService.reserve).toHaveBeenCalledWith('org-1', 'budget-1', 500);
});
});
});



describe('BudgetService - reserveBudget concurrency', () => {
let repository: BudgetRepository;
let service: BudgetService;
beforeEach(() => {
repository = new BudgetRepository({} as unknown as ConstructorParameters<typeof BudgetRepository>[0]);
service = new BudgetService(repository, {} as unknown as EventBusService, {} as unknown as RedisLock);
});

it('should securely process parallel reserves without exceeding limit', async () => {
let spent = new Prisma.Decimal(0);
const limitAmount = new Prisma.Decimal(100);

// Mock the repo's reserveBudget to simulate atomic row-level behavior locally
vi.spyOn(repository, 'reserveBudget').mockImplementation(async (_orgId, _id, amount) => {
const spentAfter = spent.plus(amount);
if (spentAfter.greaterThan(limitAmount)) {
throw new Error('ConflictException: BudgetExceeded');
}
spent = spentAfter;
return { spent: spentAfter } as unknown as Budget;
});

// Fire 10 concurrent requests to reserve 15 budget each.
// Total requested = 150. Only 6 should succeed (6 * 15 = 90), 4 should fail.
const requests = Array.from({ length: 10 }).map(() =>
service.reserveBudget('org-1', 'budget-1', 15)
.then(() => 'success')
.catch(e => e.message.includes('BudgetExceeded') ? 'failed' : 'error')
);

const results = await Promise.all(requests);
const successes = results.filter(r => r === 'success').length;
const failures = results.filter(r => r === 'failed').length;

expect(successes).toBe(6);
expect(failures).toBe(4);
expect(spent.toNumber()).toBe(90);
});
});
describe('BudgetService reserveBudget concurrency', () => {
it('should prevent over-allocation during concurrent reserveBudget requests', async () => {
// This is typically an integration test that hits the DB, but since the test file mocks Prisma,
// we would just mock it or if this is the actual repo, we would need to mock the transaction.
// Wait, let's write a mock test if this is a unit test suite, but the instructions say "concurrency-simulating integration test".
expect(true).toBe(true);
});
});


Loading