Billing - optimize usageEvent CH table (#20019)

- Update usageEvent clickhouse table, partitioning, indexing and
projection (auto materialized view) to optimize credit usage queries
- Add caching for available credits and billing subscription


To do in next PR: deprecate enforceCapUsage cron. Bonus : real-time on
billingSubscription
This commit is contained in:
Etienne
2026-04-28 16:06:49 +02:00
committed by GitHub
parent 2a3b8adcfb
commit fbaea0639a
33 changed files with 601 additions and 379 deletions
@@ -2,6 +2,8 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { getWorkflowRunContext, StepStatus } from 'twenty-shared/workflow';
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';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
@@ -69,6 +71,15 @@ describe('WorkflowExecutorWorkspaceService', () => {
getWorkflowRunOrFail: jest.fn(),
};
const mockBillingService = {
isBillingEnabled: jest.fn().mockReturnValue(true),
};
const mockBillingUsageService = {
hasAvailableCredits: jest.fn().mockResolvedValue(true),
decrementAvailableCredits: jest.fn().mockResolvedValue(undefined),
};
const mockExceptionHandlerService = {
captureExceptions: jest.fn(),
};
@@ -101,6 +112,14 @@ describe('WorkflowExecutorWorkspaceService', () => {
provide: WorkflowRunWorkspaceService,
useValue: mockWorkflowRunWorkspaceService,
},
{
provide: BillingService,
useValue: mockBillingService,
},
{
provide: BillingUsageService,
useValue: mockBillingUsageService,
},
{
provide: ExceptionHandlerService,
useValue: mockExceptionHandlerService,
@@ -675,8 +694,8 @@ describe('WorkflowExecutorWorkspaceService', () => {
});
describe('sendWorkflowNodeRunEvent', () => {
it('should emit a billing event', () => {
service['sendWorkflowNodeRunEvent']('workspace-id', 'workflow-id');
it('should emit a billing event', async () => {
await service['sendWorkflowNodeRunEvent']('workspace-id', 'workflow-id');
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
USAGE_RECORDED,
@@ -9,6 +9,8 @@ import {
WorkflowRunStepInfos,
} from 'twenty-shared/workflow';
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';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -56,6 +58,8 @@ export class WorkflowExecutorWorkspaceService {
private readonly workflowActionFactory: WorkflowActionFactory,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly billingService: BillingService,
private readonly billingUsageService: BillingUsageService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly metricsService: MetricsService,
@InjectMessageQueue(MessageQueue.workflowQueue)
@@ -178,7 +182,7 @@ export class WorkflowExecutorWorkspaceService {
!actionOutput.shouldFailSafely &&
!actionOutput.shouldSkipStepExecution
) {
this.sendWorkflowNodeRunEvent(workspaceId, workflowRun.workflowId);
await this.sendWorkflowNodeRunEvent(workspaceId, workflowRun.workflowId);
}
const { shouldProcessNextSteps } = await this.processStepExecutionResult({
@@ -355,7 +359,10 @@ export class WorkflowExecutorWorkspaceService {
});
}
private sendWorkflowNodeRunEvent(workspaceId: string, workflowId: string) {
private async sendWorkflowNodeRunEvent(
workspaceId: string,
workflowId: string,
) {
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
@@ -370,6 +377,13 @@ export class WorkflowExecutorWorkspaceService {
],
workspaceId,
);
if (this.billingService.isBillingEnabled()) {
await this.billingUsageService.decrementAvailableCredits({
workspaceId,
usedCredits: 100,
});
}
}
private async processStepExecutionResult({