Add workspace and server level stop commands for applications (#23183)
## Context When an installed application misbehaves (e.g. a logic function loop DDoSing the server or the database), we currently have no targeted way to shut it down in production: the only kill switch is `LOGIC_FUNCTION_TYPE=DISABLED`, which disables logic functions for the whole instance. This PR adds an emergency stop mechanism at two levels: - **Workspace level**: stop one installed application in its workspace. - **Server level**: stop every application installed from an `applicationRegistration`, across all workspaces. ## How it works **New nullable `stoppedAt` columns** on `core.application` and `core.applicationRegistration` (fast instance command `2.24.0`, with `up`/`down` and `@WasIntroducedInUpgrade` decorators on the entities). **Enforcement in a single choke point**: `LogicFunctionExecutorService.execute()` is the funnel behind every execution path (public route triggers, server route triggers, cron triggers, database event triggers, workflow actions, agent tool calls, manual GraphQL execution, install hooks). A new `assertApplicationNotStopped` guard runs right after the flat entities are resolved and throws `LOGIC_FUNCTION_DISABLED` (already mapped to a 403 on route triggers and handled by the GraphQL exception handler) when: - `flatApplication.stoppedAt` is set (workspace-level stop, read from the cached flat application maps: zero extra runtime cost), or - the linked registration is stopped (one indexed PK lookup, same pattern as the existing per-execution server-variable query). **Propagation**: the workspace-level stop invalidates and recomputes `flatApplicationMaps` for the workspace, so all server instances pick the flag up within the local cache TTL (100ms). The registration-level flag is read live, so it is effective immediately. ## Ops commands ```bash # Workspace level yarn command:prod application:stop -a <application-id> yarn command:prod application:start -a <application-id> # Server level (all applications of the registration, all workspaces) yarn command:prod application-registration:stop -r <application-registration-id> yarn command:prod application-registration:start -r <application-registration-id> ``` Each command logs what was stopped/started and, for registrations, how many installed applications are affected. ## Notes - Stopped executions fail fast at the guard, so queued trigger jobs (cron/db-event) burn a negligible amount of work while stopped. - The two flags are independent: lifting a registration-level stop does not clear workspace-level stops that were set individually, and vice versa. - Unit tests added for `ApplicationStopService`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CjEnKUACn89aSgK1wEMH2d)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23183?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+243
@@ -0,0 +1,243 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationException } from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationException } from 'src/engine/core-modules/application/application.exception';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
const APPLICATION_ID = 'application-1';
|
||||
const APPLICATION_UNIVERSAL_IDENTIFIER = 'application-universal-identifier-1';
|
||||
const APPLICATION_REGISTRATION_ID = 'application-registration-1';
|
||||
const APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER =
|
||||
'application-registration-universal-identifier-1';
|
||||
const WORKSPACE_ID = 'workspace-1';
|
||||
|
||||
describe('ApplicationStopService', () => {
|
||||
let applicationStopService: ApplicationStopService;
|
||||
|
||||
const applicationRepository = {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const applicationRegistrationRepository = {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const workspaceCacheService = {
|
||||
invalidateAndRecompute: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApplicationStopService,
|
||||
{
|
||||
provide: getRepositoryToken(ApplicationEntity),
|
||||
useValue: applicationRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ApplicationRegistrationEntity),
|
||||
useValue: applicationRegistrationRepository,
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: workspaceCacheService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
applicationStopService = module.get<ApplicationStopService>(
|
||||
ApplicationStopService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('stopApplication', () => {
|
||||
it('should set stoppedAt and invalidate the workspace application cache', async () => {
|
||||
applicationRepository.findOne.mockResolvedValue({
|
||||
id: APPLICATION_ID,
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
stoppedAt: null,
|
||||
});
|
||||
|
||||
const application = await applicationStopService.stopApplication({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
expect(applicationRepository.update).toHaveBeenCalledWith(
|
||||
APPLICATION_ID,
|
||||
{ stoppedAt: expect.any(Date) },
|
||||
);
|
||||
expect(workspaceCacheService.invalidateAndRecompute).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
['flatApplicationMaps'],
|
||||
);
|
||||
expect(application.stoppedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should throw when the application does not exist', async () => {
|
||||
applicationRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applicationStopService.stopApplication({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
}),
|
||||
).rejects.toThrow(ApplicationException);
|
||||
|
||||
expect(applicationRepository.update).not.toHaveBeenCalled();
|
||||
expect(
|
||||
workspaceCacheService.invalidateAndRecompute,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startApplication', () => {
|
||||
it('should clear stoppedAt and invalidate the workspace application cache', async () => {
|
||||
applicationRepository.findOne.mockResolvedValue({
|
||||
id: APPLICATION_ID,
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
stoppedAt: new Date(),
|
||||
});
|
||||
|
||||
const application = await applicationStopService.startApplication({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
expect(applicationRepository.update).toHaveBeenCalledWith(
|
||||
APPLICATION_ID,
|
||||
{ stoppedAt: null },
|
||||
);
|
||||
expect(workspaceCacheService.invalidateAndRecompute).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
['flatApplicationMaps'],
|
||||
);
|
||||
expect(application.stoppedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw when the application does not exist', async () => {
|
||||
applicationRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applicationStopService.startApplication({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
}),
|
||||
).rejects.toThrow(ApplicationException);
|
||||
|
||||
expect(applicationRepository.update).not.toHaveBeenCalled();
|
||||
expect(
|
||||
workspaceCacheService.invalidateAndRecompute,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopApplicationRegistration', () => {
|
||||
it('should set stoppedAt on the registration and report the installed application count', async () => {
|
||||
applicationRegistrationRepository.findOne.mockResolvedValue({
|
||||
id: APPLICATION_REGISTRATION_ID,
|
||||
universalIdentifier: APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'My App',
|
||||
stoppedAt: null,
|
||||
});
|
||||
applicationRepository.count.mockResolvedValue(3);
|
||||
|
||||
const { applicationRegistration, installedApplicationCount } =
|
||||
await applicationStopService.stopApplicationRegistration({
|
||||
applicationRegistrationUniversalIdentifier:
|
||||
APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
expect(applicationRegistrationRepository.update).toHaveBeenCalledWith(
|
||||
APPLICATION_REGISTRATION_ID,
|
||||
{ stoppedAt: expect.any(Date) },
|
||||
);
|
||||
expect(applicationRegistration.stoppedAt).toBeInstanceOf(Date);
|
||||
expect(installedApplicationCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should throw when the registration does not exist', async () => {
|
||||
applicationRegistrationRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applicationStopService.stopApplicationRegistration({
|
||||
applicationRegistrationUniversalIdentifier:
|
||||
APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER,
|
||||
}),
|
||||
).rejects.toThrow(ApplicationRegistrationException);
|
||||
|
||||
expect(applicationRegistrationRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startApplicationRegistration', () => {
|
||||
it('should clear stoppedAt on the registration', async () => {
|
||||
applicationRegistrationRepository.findOne.mockResolvedValue({
|
||||
id: APPLICATION_REGISTRATION_ID,
|
||||
universalIdentifier: APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'My App',
|
||||
stoppedAt: new Date(),
|
||||
});
|
||||
applicationRepository.count.mockResolvedValue(1);
|
||||
|
||||
const { applicationRegistration } =
|
||||
await applicationStopService.startApplicationRegistration({
|
||||
applicationRegistrationUniversalIdentifier:
|
||||
APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
expect(applicationRegistrationRepository.update).toHaveBeenCalledWith(
|
||||
APPLICATION_REGISTRATION_ID,
|
||||
{ stoppedAt: null },
|
||||
);
|
||||
expect(applicationRegistration.stoppedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw when the registration does not exist', async () => {
|
||||
applicationRegistrationRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
applicationStopService.startApplicationRegistration({
|
||||
applicationRegistrationUniversalIdentifier:
|
||||
APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER,
|
||||
}),
|
||||
).rejects.toThrow(ApplicationRegistrationException);
|
||||
|
||||
expect(applicationRegistrationRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isApplicationRegistrationStopped', () => {
|
||||
it('should return true when a stopped registration matches', async () => {
|
||||
applicationRegistrationRepository.count.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
applicationStopService.isApplicationRegistrationStopped(
|
||||
APPLICATION_REGISTRATION_ID,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when the registration is not stopped', async () => {
|
||||
applicationRegistrationRepository.count.mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
applicationStopService.isApplicationRegistrationStopped(
|
||||
APPLICATION_REGISTRATION_ID,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
@@ -162,6 +162,7 @@ export class ApplicationSyncService {
|
||||
settingsCustomTabFrontComponentId: null,
|
||||
canBeUninstalled: true,
|
||||
autoUpgrade: false,
|
||||
stoppedAt: null,
|
||||
isSdkLayerStale: false,
|
||||
sdkClientCoreChecksum: null,
|
||||
applicationRegistrationId: null,
|
||||
|
||||
+10
@@ -138,6 +138,16 @@ export class ApplicationRegistrationEntity {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isPreInstalled: boolean;
|
||||
|
||||
// Emergency kill switch: while set, every logic function execution of every
|
||||
// application installed from this registration is blocked, across all
|
||||
// workspaces (see `application-registration:stop` command).
|
||||
@Column({ nullable: true, type: 'timestamptz' })
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
'2.24.0_AddStoppedAtToApplicationAndApplicationRegistrationFastInstanceCommand_1784734278506',
|
||||
})
|
||||
stoppedAt: Date | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
manifest: Manifest | null;
|
||||
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
// Emergency kill switch for applications DDoSing the server or the database.
|
||||
// Workspace level: stops one installed application. Server level: stops every
|
||||
// application installed from an application registration, in all workspaces.
|
||||
@Injectable()
|
||||
export class ApplicationStopService {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
async stopApplication({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
}): Promise<ApplicationEntity> {
|
||||
return this.setApplicationStoppedAt({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
stoppedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async startApplication({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
}): Promise<ApplicationEntity> {
|
||||
return this.setApplicationStoppedAt({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
stoppedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
async stopApplicationRegistration({
|
||||
applicationRegistrationUniversalIdentifier,
|
||||
}: {
|
||||
applicationRegistrationUniversalIdentifier: string;
|
||||
}): Promise<{
|
||||
applicationRegistration: ApplicationRegistrationEntity;
|
||||
installedApplicationCount: number;
|
||||
}> {
|
||||
return this.setApplicationRegistrationStoppedAt({
|
||||
applicationRegistrationUniversalIdentifier,
|
||||
stoppedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async startApplicationRegistration({
|
||||
applicationRegistrationUniversalIdentifier,
|
||||
}: {
|
||||
applicationRegistrationUniversalIdentifier: string;
|
||||
}): Promise<{
|
||||
applicationRegistration: ApplicationRegistrationEntity;
|
||||
installedApplicationCount: number;
|
||||
}> {
|
||||
return this.setApplicationRegistrationStoppedAt({
|
||||
applicationRegistrationUniversalIdentifier,
|
||||
stoppedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
async isApplicationRegistrationStopped(
|
||||
applicationRegistrationId: string,
|
||||
): Promise<boolean> {
|
||||
const stoppedApplicationRegistrationCount =
|
||||
await this.applicationRegistrationRepository.count({
|
||||
where: {
|
||||
id: applicationRegistrationId,
|
||||
stoppedAt: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
return stoppedApplicationRegistrationCount > 0;
|
||||
}
|
||||
|
||||
private async setApplicationStoppedAt({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
stoppedAt,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
stoppedAt: Date | null;
|
||||
}): Promise<ApplicationEntity> {
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: {
|
||||
workspaceId,
|
||||
universalIdentifier: applicationUniversalIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(application)) {
|
||||
throw new ApplicationException(
|
||||
`Application ${applicationUniversalIdentifier} not found in workspace ${workspaceId}`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.applicationRepository.update(application.id, { stoppedAt });
|
||||
|
||||
// The executor reads applications from the workspace cache, so the flag
|
||||
// only takes effect once the cached flat application maps are rebuilt.
|
||||
await this.workspaceCacheService.invalidateAndRecompute(
|
||||
application.workspaceId,
|
||||
['flatApplicationMaps'],
|
||||
);
|
||||
|
||||
return { ...application, stoppedAt };
|
||||
}
|
||||
|
||||
private async setApplicationRegistrationStoppedAt({
|
||||
applicationRegistrationUniversalIdentifier,
|
||||
stoppedAt,
|
||||
}: {
|
||||
applicationRegistrationUniversalIdentifier: string;
|
||||
stoppedAt: Date | null;
|
||||
}): Promise<{
|
||||
applicationRegistration: ApplicationRegistrationEntity;
|
||||
installedApplicationCount: number;
|
||||
}> {
|
||||
const applicationRegistration =
|
||||
await this.applicationRegistrationRepository.findOne({
|
||||
where: {
|
||||
universalIdentifier: applicationRegistrationUniversalIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(applicationRegistration)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Application registration ${applicationRegistrationUniversalIdentifier} not found`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.applicationRegistrationRepository.update(
|
||||
applicationRegistration.id,
|
||||
{ stoppedAt },
|
||||
);
|
||||
|
||||
const installedApplicationCount = await this.applicationRepository.count({
|
||||
where: { applicationRegistrationId: applicationRegistration.id },
|
||||
});
|
||||
|
||||
return {
|
||||
applicationRegistration: { ...applicationRegistration, stoppedAt },
|
||||
installedApplicationCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,15 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
|
||||
})
|
||||
autoUpgrade: boolean;
|
||||
|
||||
// Emergency kill switch: while set, every logic function execution of this
|
||||
// application is blocked in this workspace (see `application:stop` command).
|
||||
@Column({ nullable: true, type: 'timestamptz' })
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
'2.24.0_AddStoppedAtToApplicationAndApplicationRegistrationFastInstanceCommand_1784734278506',
|
||||
})
|
||||
stoppedAt: Date | null;
|
||||
|
||||
@Column({ nullable: false, type: 'boolean', default: false })
|
||||
isSdkLayerStale: boolean;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationGaugeService } from 'src/engine/core-modules/application/application-gauge.service';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
@@ -40,10 +41,15 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
FeatureFlagModule,
|
||||
MetricsModule,
|
||||
],
|
||||
exports: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
|
||||
exports: [
|
||||
ApplicationService,
|
||||
ApplicationStopService,
|
||||
WorkspaceFlatApplicationMapCacheService,
|
||||
],
|
||||
providers: [
|
||||
ApplicationResolver,
|
||||
ApplicationService,
|
||||
ApplicationStopService,
|
||||
ApplicationGaugeService,
|
||||
WorkspaceFlatApplicationMapCacheService,
|
||||
provideWorkspaceScopedRepository(AgentEntity),
|
||||
|
||||
+34
-2
@@ -21,6 +21,7 @@ import {
|
||||
import { buildApplicationLogEnvelopes } from 'src/engine/core-modules/event-logs/producers/application-log/build-application-log-envelopes';
|
||||
import { parseApplicationLogLines } from 'src/engine/core-modules/event-logs/producers/application-log/parse-application-log-lines';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
|
||||
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import type { FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
|
||||
import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
@@ -92,6 +93,7 @@ export class LogicFunctionExecutorService {
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationStopService: ApplicationStopService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(ApplicationRegistrationVariableEntity)
|
||||
@@ -113,14 +115,18 @@ export class LogicFunctionExecutorService {
|
||||
userWorkspaceId?: string;
|
||||
executionMode?: LogicFunctionExecutionMode;
|
||||
}): Promise<LogicFunctionExecuteResult> {
|
||||
await this.throttleExecution(workspaceId);
|
||||
|
||||
const { flatApplication, flatLogicFunction, flatApplicationVariables } =
|
||||
await this.getFlatEntitiesOrThrow({
|
||||
workspaceId,
|
||||
logicFunctionId,
|
||||
});
|
||||
|
||||
// Checked before the shared workspace throttle so a flood from a stopped
|
||||
// application cannot exhaust the token bucket of the other applications.
|
||||
await this.assertApplicationNotStopped(flatApplication);
|
||||
|
||||
await this.throttleExecution(workspaceId);
|
||||
|
||||
const envVariables = await this.getExecutionEnvVariables({
|
||||
workspaceId,
|
||||
flatApplication,
|
||||
@@ -233,6 +239,32 @@ export class LogicFunctionExecutorService {
|
||||
return driver.transpile(params);
|
||||
}
|
||||
|
||||
// Emergency kill switch checked on every execution: an application can be
|
||||
// stopped for one workspace (application.stoppedAt) or server-wide for all
|
||||
// workspaces (applicationRegistration.stoppedAt) when it degrades production.
|
||||
private async assertApplicationNotStopped(
|
||||
flatApplication: FlatApplication,
|
||||
): Promise<void> {
|
||||
if (isDefined(flatApplication.stoppedAt)) {
|
||||
throw new LogicFunctionException(
|
||||
`Application ${flatApplication.id} is stopped in workspace ${flatApplication.workspaceId}`,
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(flatApplication.applicationRegistrationId) &&
|
||||
(await this.applicationStopService.isApplicationRegistrationStopped(
|
||||
flatApplication.applicationRegistrationId,
|
||||
))
|
||||
) {
|
||||
throw new LogicFunctionException(
|
||||
`Application registration ${flatApplication.applicationRegistrationId} is stopped server-wide`,
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async throttleExecution(workspaceId: string) {
|
||||
try {
|
||||
await this.throttlerService.tokenBucketThrottleOrThrow(
|
||||
|
||||
+27
-5
@@ -1,9 +1,13 @@
|
||||
import { Scope } from '@nestjs/common';
|
||||
import { Logger, Scope } from '@nestjs/common';
|
||||
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
|
||||
|
||||
export type LogicFunctionTriggerJobData = {
|
||||
logicFunctionId: string;
|
||||
@@ -18,6 +22,8 @@ export type LogicFunctionTriggerJobData = {
|
||||
scope: Scope.REQUEST,
|
||||
})
|
||||
export class LogicFunctionTriggerJob {
|
||||
private readonly logger = new Logger(LogicFunctionTriggerJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
|
||||
) {}
|
||||
@@ -25,16 +31,32 @@ export class LogicFunctionTriggerJob {
|
||||
@Process(LogicFunctionTriggerJob.name)
|
||||
async handle(logicFunctionPayloads: LogicFunctionTriggerJobData[]) {
|
||||
await Promise.all(
|
||||
logicFunctionPayloads.map(
|
||||
async (logicFunctionPayload) =>
|
||||
logicFunctionPayloads.map(async (logicFunctionPayload) => {
|
||||
try {
|
||||
await this.logicFunctionExecutorService.execute({
|
||||
logicFunctionId: logicFunctionPayload.logicFunctionId,
|
||||
workspaceId: logicFunctionPayload.workspaceId,
|
||||
payload: logicFunctionPayload.payload ?? {},
|
||||
userId: logicFunctionPayload.userId,
|
||||
userWorkspaceId: logicFunctionPayload.userWorkspaceId,
|
||||
}),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
// A stopped application must not fail the job: failing would make
|
||||
// the queue retry an execution that is intentionally blocked.
|
||||
if (
|
||||
error instanceof LogicFunctionException &&
|
||||
error.code === LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Skipping execution of logic function ${logicFunctionPayload.logicFunctionId} in workspace ${logicFunctionPayload.workspaceId}: ${error.message}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -12,6 +12,10 @@ import { type MessageQueueService } from 'src/engine/core-modules/message-queue/
|
||||
import { ServerRouteTriggerExceptionCode } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
|
||||
import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service';
|
||||
import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
|
||||
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
|
||||
|
||||
const RESOLVER_UID = 'resolver-uid';
|
||||
@@ -302,6 +306,20 @@ describe('ServerRouteTriggerService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a LogicFunctionException(LOGIC_FUNCTION_DISABLED) to the server-route disabled code', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockRejectedValue(
|
||||
new LogicFunctionException(
|
||||
'application is stopped',
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_DISABLED,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak the raw resolver executor error message to the caller', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockRejectedValue(
|
||||
|
||||
+9
@@ -30,6 +30,15 @@ export class ServerRouteTriggerRestApiExceptionFilter implements ExceptionFilter
|
||||
response,
|
||||
404,
|
||||
);
|
||||
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_DISABLED:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
403,
|
||||
undefined,
|
||||
undefined,
|
||||
{ shouldBeCapturedBySentry: false },
|
||||
);
|
||||
case ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
|
||||
+3
@@ -6,6 +6,7 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum ServerRouteTriggerExceptionCode {
|
||||
LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND',
|
||||
LOGIC_FUNCTION_DISABLED = 'LOGIC_FUNCTION_DISABLED',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
SERVER_ROUTE_USER_UNCAUGHT_ERROR = 'SERVER_ROUTE_USER_UNCAUGHT_ERROR',
|
||||
SERVER_ROUTE_PLATFORM_ERROR = 'SERVER_ROUTE_PLATFORM_ERROR',
|
||||
@@ -19,6 +20,8 @@ const getServerRouteTriggerExceptionUserFriendlyMessage = (
|
||||
switch (code) {
|
||||
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return msg`Server logic function not found.`;
|
||||
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_DISABLED:
|
||||
return msg`This action is currently unavailable.`;
|
||||
case ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED:
|
||||
return msg`Rate limit exceeded.`;
|
||||
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR:
|
||||
|
||||
+13
@@ -25,6 +25,10 @@ import {
|
||||
ServerRouteTriggerExceptionCode,
|
||||
} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
|
||||
|
||||
type ResolverResult = {
|
||||
workspaceId: string;
|
||||
@@ -274,6 +278,8 @@ export class ServerRouteTriggerService {
|
||||
return 'Rate limit exceeded';
|
||||
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return 'Logic function not found';
|
||||
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_DISABLED:
|
||||
return 'Logic function execution is disabled';
|
||||
default:
|
||||
return 'An unexpected error occurred while handling the server route';
|
||||
}
|
||||
@@ -282,6 +288,13 @@ export class ServerRouteTriggerService {
|
||||
private mapExecutorErrorToServerRouteCode(
|
||||
error: unknown,
|
||||
): ServerRouteTriggerExceptionCode {
|
||||
if (
|
||||
error instanceof LogicFunctionException &&
|
||||
error.code === LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED
|
||||
) {
|
||||
return ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_DISABLED;
|
||||
}
|
||||
|
||||
if (!(error instanceof LogicFunctionExecutionException)) {
|
||||
return ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR;
|
||||
}
|
||||
|
||||
+1
@@ -42,6 +42,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
|
||||
settingsCustomTabFrontComponentId: null,
|
||||
canBeUninstalled: false,
|
||||
autoUpgrade: false,
|
||||
stoppedAt: null,
|
||||
applicationRegistrationId: null,
|
||||
primaryPublicDomainId: null,
|
||||
createdAt: new Date(),
|
||||
|
||||
+1
@@ -37,6 +37,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
|
||||
settingsCustomTabFrontComponentId: null,
|
||||
canBeUninstalled: false,
|
||||
autoUpgrade: false,
|
||||
stoppedAt: null,
|
||||
applicationRegistrationId: null,
|
||||
primaryPublicDomainId: null,
|
||||
createdAt: new Date(),
|
||||
|
||||
Reference in New Issue
Block a user