Compare commits

...
Author SHA1 Message Date
sonarly-bot 326508ff1c fix(billing): avoid timeout in cap-flag subscription lookup
https://sonarly.com/issue/38542?type=bug

A worker-side billing query timed out while updating the “has reached cap” flag, causing failures during post-execution billing for an affected workspace. The failure is in server billing logic, not the frontend change shipped in v2.6.1.

Fix: Implemented a production fix in the exact timeout path:

1) Reduced query cost in `setSubscriptionItemHasReachedCap`:
- Changed lookup to use `billingSubscriptionId` (already available from workspace cache in caller) instead of joining through `billingSubscription.workspaceId + status`.
- Switched from `find(...)` (array load) to `findOne(...)` selecting only needed fields (`id`, `hasReachedCurrentPeriodCap`).
- Added a no-op short-circuit when the cap flag already matches target state, avoiding unnecessary updates.

2) Reduced call frequency for cap-flag lookup:
- In `decrementAvailableCreditsInCache`, cap update now runs only on threshold crossing (`availableCredits > 0 && decrementedAvailableCredits <= 0`) rather than on every decrement while already negative.
- Passed `billingSubscriptionId` from workspace cache into cap service.

This directly addresses the expensive worker-side cap-flag subscription-item query that timed out.

Authored by Sonarly by autonomous analysis (run 43909).
2026-05-18 23:11:09 +00:00
5 changed files with 90 additions and 16 deletions
@@ -14,6 +14,10 @@ describe('BillingUsageCapService', () => {
let service: BillingUsageCapService;
let clickHouseService: jest.Mocked<ClickHouseService>;
let twentyConfigService: jest.Mocked<TwentyConfigService>;
let billingSubscriptionItemRepository: {
findOne: jest.Mock;
update: jest.Mock;
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -40,7 +44,7 @@ describe('BillingUsageCapService', () => {
{
provide: getRepositoryToken(BillingSubscriptionItemEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
},
},
@@ -50,6 +54,9 @@ describe('BillingUsageCapService', () => {
service = module.get<BillingUsageCapService>(BillingUsageCapService);
clickHouseService = module.get(ClickHouseService);
twentyConfigService = module.get(TwentyConfigService);
billingSubscriptionItemRepository = module.get(
getRepositoryToken(BillingSubscriptionItemEntity),
);
});
afterEach(() => {
@@ -70,6 +77,41 @@ describe('BillingUsageCapService', () => {
});
});
describe('setSubscriptionItemHasReachedCap', () => {
it('updates the subscription item when cap flag changes', async () => {
billingSubscriptionItemRepository.findOne.mockResolvedValue({
id: 'subscription-item-id',
hasReachedCurrentPeriodCap: false,
});
await service.setSubscriptionItemHasReachedCap(
'subscription-id',
'workspace-id',
true,
);
expect(billingSubscriptionItemRepository.update).toHaveBeenCalledWith(
{ id: 'subscription-item-id' },
{ hasReachedCurrentPeriodCap: true },
);
});
it('does not update when cap flag is already set', async () => {
billingSubscriptionItemRepository.findOne.mockResolvedValue({
id: 'subscription-item-id',
hasReachedCurrentPeriodCap: true,
});
await service.setSubscriptionItemHasReachedCap(
'subscription-id',
'workspace-id',
true,
);
expect(billingSubscriptionItemRepository.update).not.toHaveBeenCalled();
});
});
describe('getBatchPeriodCreditsUsed', () => {
beforeEach(() => {
twentyConfigService.get.mockReturnValue('http://clickhouse:8123');
@@ -11,10 +11,9 @@ import {
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Not, Raw, Repository } from 'typeorm';
import { Raw, Repository } from 'typeorm';
export type BillingCapEvaluation =
| {
@@ -83,16 +82,18 @@ export class BillingUsageCapService {
}
async setSubscriptionItemHasReachedCap(
billingSubscriptionId: string,
workspaceId: string,
hasReachedCap: boolean,
): Promise<void> {
const billingSubscriptionItems =
await this.billingSubscriptionItemRepository.find({
const billingSubscriptionItem =
await this.billingSubscriptionItemRepository.findOne({
select: {
id: true,
hasReachedCurrentPeriodCap: true,
},
where: {
billingSubscription: {
workspaceId,
status: Not(SubscriptionStatus.Canceled),
},
billingSubscriptionId,
billingProduct: {
metadata: Raw((alias) => `${alias} @> :metadata::jsonb`, {
metadata: JSON.stringify({
@@ -103,15 +104,19 @@ export class BillingUsageCapService {
},
});
if (billingSubscriptionItems.length !== 1) {
if (!billingSubscriptionItem) {
throw new BillingException(
`Expected 1 billing subscription item for workspace ${workspaceId}, but got ${billingSubscriptionItems.length}`,
`Resource credit subscription item not found for workspace ${workspaceId}`,
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
if (billingSubscriptionItem.hasReachedCurrentPeriodCap === hasReachedCap) {
return;
}
await this.billingSubscriptionItemRepository.update(
{ id: billingSubscriptionItems[0].id },
{ id: billingSubscriptionItem.id },
{ hasReachedCurrentPeriodCap: hasReachedCap },
);
}
@@ -282,7 +282,11 @@ export class BillingUsageService {
usedCredits: number;
}): Promise<number> {
const {
billingSubscription: { currentPeriodStart, currentPeriodEnd },
billingSubscription: {
id: billingSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
},
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'billingSubscription',
]);
@@ -317,8 +321,9 @@ export class BillingUsageService {
-usedCredits,
);
if (decrementedAvailableCredits <= 0) {
if (availableCredits > 0 && decrementedAvailableCredits <= 0) {
await this.billingUsageCapService.setSubscriptionItemHasReachedCap(
billingSubscriptionId,
workspaceId,
true,
);
@@ -15,6 +15,10 @@ import {
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
@@ -407,6 +411,17 @@ export class ChatExecutionService {
if (error?.name === 'AbortError') {
return;
}
const isBillingCreditsExhaustedError =
error instanceof BillingException &&
error.code === BillingExceptionCode.BILLING_CREDITS_EXHAUSTED;
if (isBillingCreditsExhaustedError) {
this.logger.warn('AI stream aborted because billing credits are exhausted');
return;
}
this.exceptionHandlerService.captureExceptions([error]);
});
@@ -9,6 +9,10 @@ import {
WorkflowRunStepInfos,
} from 'twenty-shared/workflow';
import {
BillingException,
BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@@ -508,13 +512,16 @@ export class WorkflowExecutorWorkspaceService {
},
});
} catch (error) {
const isUserError =
const isWorkflowUserError =
error instanceof WorkflowStepExecutorException &&
(error.code === WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE ||
error.code === WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT ||
error.code === WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND);
const isBillingCreditsExhaustedError =
error instanceof BillingException &&
error.code === BillingExceptionCode.BILLING_CREDITS_EXHAUSTED;
if (!isUserError) {
if (!isWorkflowUserError && !isBillingCreditsExhaustedError) {
this.exceptionHandlerService.captureExceptions([error], {
workspace: { id: workspaceId },
});