From a7324252fd690b46f0688a35729ce80943bbf160 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 6 Aug 2026 15:45:37 +0200 Subject: [PATCH] fix(twenty-server): stop the yarn-install Lambda from running out of memory (#23805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Sentry issue [7438578272](https://twenty-v7.sentry.io/issues/7438578272/) (Logic Function Layer Build Failed, 7.9K events over 3 months): the yarn-install tool Lambda dies with `Runtime.OutOfMemory` / `signal: killed` while building an application's dependency layer. Every layer build for the affected application fails permanently, each database-event trigger re-attempts it, and one workspace produced ~2.5K events in the last week alone. ## Root cause The offending application declares `twenty-ui@1.0.0-alpha.0` (181MB unpacked, dragging in 141MB of `@tabler/icons*`) and dev tooling as production `dependencies` of server-side logic functions. Installing that tree needs just under 3GB during Yarn 4's fetch/link phase, so the 1024MB sandbox is OOM-killed. And even a successful install could never ship: AWS caps a function plus all its layers at 250MB unzipped. The user never sees any of this: the OOM is retried forever, and nothing tells them their dependencies are the problem. ## Fix 1. **Raise the yarn-install Lambda to 4096MB** so legitimate dependency trees install. Tool function names now include the memory/timeout/ephemeral-storage constants in their content hash, so a config change rotates the function name and the ensure path creates a fresh function with the new configuration — without this, the constant change would never reach already-deployed functions (their config is only applied at creation, and the ensure path early-returns when the function exists). 2. **Propagate Lambda's own errors to the user.** The install OOM (`Runtime.OutOfMemory` on the invoke) and the layer size rejection (`InvalidParameterValueException` at `PublishLayerVersion`) map to a new `LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED` code telling the user to move packages their logic functions don't import out of `dependencies`. Surfacing per API boundary: - **Sync / install (CLI)**: the workspace migration interceptor formats it into the same metadata validation error shape the SDK already renders, as one `logicFunction` entry carrying the remedy and the underlying AWS detail — no SDK rendering changes needed. - **`executeOneLogicFunction`**: mapped to `UserInputError` in the GraphQL handler. - **Route triggers**: HTTP 422 with the user-facing message, no Sentry capture. - **Background triggers**: skip instead of retrying, since no retry can succeed until the user changes their application. ## Test The error originates in AWS behavior, which CI (local driver, no AWS) cannot reproduce — so the chain is verified link by link: - **Real AWS, manual (not in CI)**: reproduced with the offending application's actual package.json against real Lambdas in the dev account — OOM-killed at 1024MB and 2048MB (exact prod error signature), install succeeds at 4096MB (~4min), and the resulting 292MB layer is rejected by `PublishLayerVersion` with the exact `InvalidParameterValueException` this PR matches. Same matrix reproduced in local cgroups beforehand. - **Server unit specs**: AWS error payload → exception mapping (`build-yarn-install-failure-exception`), exception → validation payload formatting (interceptor handler), `executeOneLogicFunction` GraphQL mapping, route filter 422 mapping, tool-function/layer name hashing. - **SDK integration spec (mocked server)**: runs the real `app dev` orchestrator on the minimal app with `syncApplication` mocked to return the validation-shaped failure, and asserts the CLI report renders the error code and remedy. It covers CLI rendering only — no test installs actual oversized dependencies, by design. - Docs updated (dependency size limits, sync failure taxonomy, route platform error responses). --- .../getting-started/project-structure.mdx | 2 +- .../extend/apps/logic/logic-functions.mdx | 4 ++ .../apps/operations/sync-and-recovery.mdx | 1 + .../sync-error-surfacing.integration.spec.ts | 70 +++++++++++++++++++ .../integration/utils/setup-app-dev-mocks.ts | 2 +- .../constants/lambda-driver.constant.ts | 2 +- .../lambda-executor-manager.service.ts | 8 +++ .../services/lambda-layer-manager.service.ts | 48 +++++++++---- .../services/lambda-tool-functions.service.ts | 20 ++++-- ...arn-install-failure-exception.util.spec.ts | 34 +++++++++ ...ild-yarn-install-failure-exception.util.ts | 25 +++++++ .../jobs/logic-function-trigger.job.ts | 15 +++- ...-trigger-rest-api-exception-filter.spec.ts | 18 +++++ ...route-trigger-rest-api-exception-filter.ts | 9 +++ .../exceptions/route-trigger.exception.ts | 3 + .../triggers/route/route-trigger.service.ts | 2 + .../logic-function.exception.ts | 3 + ...raphql-api-exception-handler.utils.spec.ts | 62 ++++++++++++++++ ...ion-graphql-api-exception-handler.utils.ts | 1 + ...graphql-api-exception-handler.util.spec.ts | 43 ++++++++++++ ...size-graphql-api-exception-handler.util.ts | 43 ++++++++++++ ...ation-graphql-api-exception.interceptor.ts | 13 ++++ 22 files changed, 405 insertions(+), 23 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/sync-error-surfacing.integration.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/__tests__/build-yarn-install-failure-exception.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-yarn-install-failure-exception.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/logic-function/utils/__tests__/logic-function-graphql-api-exception-handler.utils.spec.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/utils/__tests__/logic-function-dependencies-size-graphql-api-exception-handler.util.spec.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/utils/logic-function-dependencies-size-graphql-api-exception-handler.util.ts diff --git a/packages/twenty-docs/developers/extend/apps/getting-started/project-structure.mdx b/packages/twenty-docs/developers/extend/apps/getting-started/project-structure.mdx index 8acb8eeb31..00b6ef5318 100644 --- a/packages/twenty-docs/developers/extend/apps/getting-started/project-structure.mdx +++ b/packages/twenty-docs/developers/extend/apps/getting-started/project-structure.mdx @@ -76,4 +76,4 @@ The scaffolder pins `twenty-sdk` and `twenty-client-sdk` to its own version — Keeping either package under `dependencies` pulls it into the installed app's runtime bundle, where it is dead weight. `twenty dev:build` emits a warning when either is still listed under `dependencies`. -Add your app's own runtime dependencies (libraries your logic functions actually import at runtime) under `dependencies` as usual. +Add your app's own runtime dependencies (libraries your logic functions actually import at runtime) under `dependencies` as usual. Keep the tree lean: `dependencies` are installed into a runtime layer capped at 250MB unpacked, and a sync or install fails with a dependencies size error beyond that — UI libraries and dev tooling belong in `devDependencies`. diff --git a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx index 1d295b38c2..46d8e2b72a 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx @@ -172,6 +172,10 @@ For security reasons, response headers are restricted to an allow-list. Any head The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively. +#### Platform error responses + +Beyond your handler's own responses, the platform answers route calls directly in some situations: `404` when the route or function does not exist, `403` when the application is stopped, `429` when the execution rate limit is hit, and `422` when the application's production `dependencies` are too large to install — see [dependencies size limits](/developers/extend/apps/getting-started/project-structure#dependencies). + #### Server route trigger `httpRouteTriggerSettings` exposes a function under `/s/` and resolves the workspace from the request host — which works when each workspace has its own domain. Third-party providers, however, deliver every tenant's events to **one** URL. For that case, use `serverRouteTriggerSettings`. diff --git a/packages/twenty-docs/developers/extend/apps/operations/sync-and-recovery.mdx b/packages/twenty-docs/developers/extend/apps/operations/sync-and-recovery.mdx index b6054081f0..f0a807569b 100644 --- a/packages/twenty-docs/developers/extend/apps/operations/sync-and-recovery.mdx +++ b/packages/twenty-docs/developers/extend/apps/operations/sync-and-recovery.mdx @@ -121,5 +121,6 @@ When something goes wrong, the metadata diff and named errors let you place the - **Manifest build error** — the CLI fails before syncing (`MANIFEST_BUILD_FAILED`, `TYPECHECK_FAILED`); fix your app source. - **Sync / migration error** — the build succeeds but applying the diff fails, naming the entity and `universalIdentifier`; fix the conflicting metadata. +- **Dependencies size error** — the sync or install fails because the app's production `dependencies` are too large to install as a runtime layer (`LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED`); move packages your logic functions do not import at runtime (UI libraries, dev tooling) to `devDependencies`. - **App code runtime error** — the sync succeeds but your logic functions or components misbehave at runtime; check [function logs](/developers/extend/apps/operations/cli). - **Local instance state** — none of the above and the workspace still looks wrong; work down the recovery ladder. diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/sync-error-surfacing.integration.spec.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/sync-error-surfacing.integration.spec.ts new file mode 100644 index 0000000000..0107acec7e --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/sync-error-surfacing.integration.spec.ts @@ -0,0 +1,70 @@ +import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths'; +import { mockApiService } from '@/cli/__tests__/integration/utils/setup-app-dev-mocks'; +import { AppDevCommand } from '@/cli/commands/dev/dev'; + +const DEPENDENCIES_SIZE_ERROR_CODE = + 'LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED'; +const DEPENDENCIES_SIZE_ERROR_MESSAGE = + 'Production dependencies are too large to install. Move packages that are not imported by your logic functions (UI libraries, dev tooling) out of "dependencies".'; + +describe('minimal-app dev sync error surfacing', () => { + it('should render the dependencies size error through the validation error report when the sync fails', async () => { + mockApiService.syncApplication.mockResolvedValue({ + success: false, + error: { + summary: { totalErrors: 1, logicFunction: 1 }, + errors: { + logicFunction: [ + { + type: 'update', + metadataName: 'logicFunction', + errors: [ + { + code: DEPENDENCIES_SIZE_ERROR_CODE, + message: DEPENDENCIES_SIZE_ERROR_MESSAGE, + value: + "Dependency layer 'deps-abc' exceeds the Lambda layer size limit: Unzipped size must be smaller than 262144000 bytes", + }, + ], + flatEntityMinimalInformation: {}, + }, + ], + }, + }, + message: 'Validation failed for 1 logicFunction', + }); + + const command = new AppDevCommand(); + + await command.execute({ appPath: MINIMAL_APP_PATH, headless: true }); + + try { + const deadline = Date.now() + 30_000; + let syncStepStatus: string | undefined; + + while (Date.now() < deadline) { + syncStepStatus = command.getOrchestrator()?.getState()?.steps + .syncApplication.status; + + if (syncStepStatus === 'error') { + break; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + expect(syncStepStatus).toBe('error'); + + const eventMessages = ( + command.getOrchestrator()?.getState()?.events ?? [] + ) + .map((event) => event.message) + .join('\n'); + + expect(eventMessages).toContain(DEPENDENCIES_SIZE_ERROR_CODE); + expect(eventMessages).toContain(DEPENDENCIES_SIZE_ERROR_MESSAGE); + } finally { + await command.close(); + } + }, 60_000); +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts index f71b541908..31aa92ceb2 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest'; -const mockApiService = { +export const mockApiService = { validateAuth: vi.fn().mockResolvedValue({ authValid: true, serverUp: true }), getWorkspaceFrontendUrl: vi.fn().mockResolvedValue('http://localhost:3000'), generateApplicationToken: vi.fn().mockResolvedValue({ diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant.ts index 3a95995612..bfc51baa26 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant.ts @@ -9,7 +9,7 @@ export const LAMBDA_CLIENT_MAX_ATTEMPTS = 8; export const LAMBDA_CLIENT_RETRY_MODE = 'adaptive' as const; export const YARN_INSTALL_LAMBDA_TIMEOUT_SECONDS = 300; -export const YARN_INSTALL_LAMBDA_MEMORY_MB = 1024; +export const YARN_INSTALL_LAMBDA_MEMORY_MB = 4096; export const BUILDER_LAMBDA_TIMEOUT_SECONDS = 60; export const BUILDER_LAMBDA_MEMORY_MB = 512; export const EXECUTOR_LAMBDA_MEMORY_MB = 512; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-executor-manager.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-executor-manager.service.ts index be7a5f9d8c..529ea8d598 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-executor-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-executor-manager.service.ts @@ -311,6 +311,14 @@ export class LambdaExecutorManagerService { applicationUniversalIdentifier, }); } catch (error) { + if ( + error instanceof LogicFunctionException && + error.code === + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED + ) { + throw error; + } + this.logger.error( `Failed to get dependency layer for function ${flatLogicFunction.id}: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-layer-manager.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-layer-manager.service.ts index d9b44b307d..0d6685c232 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-layer-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-layer-manager.service.ts @@ -3,6 +3,7 @@ import * as fs from 'fs/promises'; import { DeleteLayerVersionCommand, type GetFunctionCommandOutput, + InvalidParameterValueException, ListLayerVersionsCommand, PublishLayerVersionCommand, ResourceNotFoundException, @@ -22,6 +23,10 @@ import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logi import { type LogicFunctionResourceService } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service'; import { type SdkClientArchiveService } from 'src/engine/core-modules/sdk-client/sdk-client-archive.service'; import { LogicFunctionRuntime } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; type LayerAppContext = { flatApplication: FlatApplication; @@ -176,19 +181,36 @@ export class LambdaLayerManagerService { }); const lambdaClient = await this.awsClient.getLambdaClient(); - const publishResult = await lambdaClient.send( - new PublishLayerVersionCommand({ - LayerName: layerName, - Content: { - S3Bucket: this.options.layerBucket, - S3Key: s3Key, - }, - CompatibleRuntimes: [ - LogicFunctionRuntime.NODE18, - LogicFunctionRuntime.NODE22, - ], - }), - ); + + let publishResult; + + try { + publishResult = await lambdaClient.send( + new PublishLayerVersionCommand({ + LayerName: layerName, + Content: { + S3Bucket: this.options.layerBucket, + S3Key: s3Key, + }, + CompatibleRuntimes: [ + LogicFunctionRuntime.NODE18, + LogicFunctionRuntime.NODE22, + ], + }), + ); + } catch (error) { + if ( + error instanceof InvalidParameterValueException && + error.message.toLowerCase().includes('size') + ) { + throw new LogicFunctionException( + `Dependency layer '${layerName}' exceeds the Lambda layer size limit: ${error.message}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED, + ); + } + + throw error; + } if (!publishResult.LayerVersionArn) { throw new Error( diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-tool-functions.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-tool-functions.service.ts index 37ae0d9729..c7286e6da4 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-tool-functions.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-tool-functions.service.ts @@ -33,6 +33,7 @@ import { type YarnInstallLambdaPayload, type YarnInstallLambdaResult, } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/types/lambda-driver.type'; +import { buildYarnInstallFailureException } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-yarn-install-failure-exception.util'; import { computeHashedLambdaResourceName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/compute-hashed-lambda-resource-name.util'; import { type LambdaAwsClientService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-aws-client.service'; import { copyBuilder } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/copy-builder'; @@ -150,10 +151,7 @@ export class LambdaToolFunctionsService { ? JSON.parse(result.Payload.transformToString()) : {}; - throw new LogicFunctionException( - `Yarn install Lambda failed: ${JSON.stringify(parsedResult)}`, - LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED, - ); + throw buildYarnInstallFailureException(parsedResult); } const parsedResult: YarnInstallLambdaResult = result.Payload @@ -346,7 +344,12 @@ export class LambdaToolFunctionsService { this.yarnInstallFunctionName = computeHashedLambdaResourceName({ resourceNamePrefix: YARN_INSTALL_FUNCTION_NAME_PREFIX, namespace: this.options.resourceNamespace, - contents: [handlerContent], + contents: [ + handlerContent, + String(YARN_INSTALL_LAMBDA_MEMORY_MB), + String(YARN_INSTALL_LAMBDA_TIMEOUT_SECONDS), + String(LAMBDA_EPHEMERAL_STORAGE_MB), + ], }); return this.yarnInstallFunctionName; @@ -362,7 +365,12 @@ export class LambdaToolFunctionsService { this.builderFunctionName = computeHashedLambdaResourceName({ resourceNamePrefix: BUILDER_FUNCTION_NAME_PREFIX, namespace: this.options.resourceNamespace, - contents: [handlerContent], + contents: [ + handlerContent, + String(BUILDER_LAMBDA_MEMORY_MB), + String(BUILDER_LAMBDA_TIMEOUT_SECONDS), + String(LAMBDA_EPHEMERAL_STORAGE_MB), + ], }); return this.builderFunctionName; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/__tests__/build-yarn-install-failure-exception.util.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/__tests__/build-yarn-install-failure-exception.util.spec.ts new file mode 100644 index 0000000000..a338935062 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/__tests__/build-yarn-install-failure-exception.util.spec.ts @@ -0,0 +1,34 @@ +import { buildYarnInstallFailureException } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-yarn-install-failure-exception.util'; +import { LogicFunctionExceptionCode } from 'src/engine/metadata-modules/logic-function/logic-function.exception'; + +describe('buildYarnInstallFailureException', () => { + it('should map an out-of-memory kill to LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED', () => { + const exception = buildYarnInstallFailureException({ + errorType: 'Runtime.OutOfMemory', + errorMessage: 'Error: Runtime exited with error: signal: killed', + }); + + expect(exception.code).toBe( + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED, + ); + }); + + it('should map any other failure to LOGIC_FUNCTION_CREATE_FAILED', () => { + const exception = buildYarnInstallFailureException({ + errorType: 'Error', + errorMessage: 'yarn install failed: ENETDOWN', + }); + + expect(exception.code).toBe( + LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED, + ); + }); + + it('should map an empty payload to LOGIC_FUNCTION_CREATE_FAILED', () => { + const exception = buildYarnInstallFailureException({}); + + expect(exception.code).toBe( + LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-yarn-install-failure-exception.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-yarn-install-failure-exception.util.ts new file mode 100644 index 0000000000..922b293d64 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-yarn-install-failure-exception.util.ts @@ -0,0 +1,25 @@ +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; + +type YarnInstallLambdaErrorPayload = { + errorType?: string; + errorMessage?: string; +}; + +export const buildYarnInstallFailureException = ( + payload: YarnInstallLambdaErrorPayload, +): LogicFunctionException => { + if (payload.errorType === 'Runtime.OutOfMemory') { + return new LogicFunctionException( + `Yarn install Lambda ran out of memory: the dependency tree is too large to install`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED, + ); + } + + return new LogicFunctionException( + `Yarn install Lambda failed: ${JSON.stringify(payload)}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED, + ); +}; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job.ts index f3890cb2a9..b52ce45a53 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job.ts @@ -1,4 +1,4 @@ -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'; @@ -22,6 +22,8 @@ export type LogicFunctionTriggerJobData = { scope: Scope.REQUEST, }) export class LogicFunctionTriggerJob { + private readonly logger = new Logger(LogicFunctionTriggerJob.name); + constructor( private readonly logicFunctionExecutorService: LogicFunctionExecutorService, ) {} @@ -52,6 +54,17 @@ export class LogicFunctionTriggerJob { continue; } + if ( + error instanceof LogicFunctionException && + error.code === + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED + ) { + this.logger.warn( + `Skipping function ${logicFunctionPayload.logicFunctionId} (workspace ${logicFunctionPayload.workspaceId}): ${error.message}`, + ); + continue; + } + throw error; } } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/__tests__/route-trigger-rest-api-exception-filter.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/__tests__/route-trigger-rest-api-exception-filter.spec.ts index 5735ff2293..ef6c3cad3d 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/__tests__/route-trigger-rest-api-exception-filter.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/__tests__/route-trigger-rest-api-exception-filter.spec.ts @@ -58,6 +58,24 @@ describe('RouteTriggerRestApiExceptionFilter', () => { expect(handleError).toHaveBeenCalledWith(exception, response, 500); }); + it('maps oversized dependencies to 422 without Sentry capture', () => { + const exception = new RouteTriggerException( + 'dependencies too large', + RouteTriggerExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED, + ); + + filter.catch(exception, host); + + expect(handleError).toHaveBeenCalledWith( + exception, + response, + 422, + undefined, + undefined, + { shouldBeCapturedBySentry: false }, + ); + }); + it('maps a disabled function to 403', () => { const exception = new RouteTriggerException( 'disabled', diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts index e1446d42d3..cc0e5bfb60 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts @@ -52,6 +52,15 @@ export class RouteTriggerRestApiExceptionFilter implements ExceptionFilter { response, 410, ); + case RouteTriggerExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 422, + undefined, + undefined, + { shouldBeCapturedBySentry: false }, + ); case RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR: return this.httpExceptionHandlerService.handleError( exception as CustomException, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts index e469aea4f1..af1b24c20c 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts @@ -17,6 +17,7 @@ export enum RouteTriggerExceptionCode { ROUTE_TRIGGER_PLATFORM_ERROR = 'ROUTE_TRIGGER_PLATFORM_ERROR', RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', LEGACY_ROUTE_DEPRECATED = 'LEGACY_ROUTE_DEPRECATED', + LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED = 'LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED', } const getRouteTriggerExceptionUserFriendlyMessage = ( @@ -47,6 +48,8 @@ const getRouteTriggerExceptionUserFriendlyMessage = ( return msg`Too many requests. Please try again later.`; case RouteTriggerExceptionCode.LEGACY_ROUTE_DEPRECATED: return msg`This endpoint is no longer available on /s/. Use the dedicated public domain URL instead.`; + case RouteTriggerExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED: + return msg`The application's production dependencies are too large to install. Move packages that are not imported by its logic functions (UI libraries, dev tooling) out of "dependencies".`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts index 1748369b1d..e374568927 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts @@ -239,6 +239,8 @@ export class RouteTriggerService { return RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND; case LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED: return RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION; + case LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED: + return RouteTriggerExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.exception.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.exception.ts index 8c8ab0e61d..bae9240d6e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.exception.ts @@ -16,6 +16,7 @@ export enum LogicFunctionExceptionCode { LOGIC_FUNCTION_EXECUTION_TIMEOUT = 'LOGIC_FUNCTION_EXECUTION_TIMEOUT', LOGIC_FUNCTION_PLATFORM_EXECUTION_ERROR = 'LOGIC_FUNCTION_PLATFORM_EXECUTION_ERROR', LOGIC_FUNCTION_LAYER_BUILD_FAILED = 'LOGIC_FUNCTION_LAYER_BUILD_FAILED', + LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED = 'LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED', LOGIC_FUNCTION_DISABLED = 'LOGIC_FUNCTION_DISABLED', LOGIC_FUNCTION_INVALID_SEED_PROJECT = 'LOGIC_FUNCTION_INVALID_SEED_PROJECT', INVALID_LOGIC_FUNCTION_INPUT = 'INVALID_LOGIC_FUNCTION_INPUT', @@ -48,6 +49,8 @@ const getLogicFunctionExceptionUserFriendlyMessage = ( return msg`Function execution failed.`; case LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED: return msg`Failed to build function dependencies.`; + case LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED: + return msg`Your application's production dependencies are too large to install. Move packages that are not imported by your logic functions (UI libraries, dev tooling) out of "dependencies".`; case LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED: return msg`Logic function execution is disabled.`; case LogicFunctionExceptionCode.LOGIC_FUNCTION_INVALID_SEED_PROJECT: diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/__tests__/logic-function-graphql-api-exception-handler.utils.spec.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/__tests__/logic-function-graphql-api-exception-handler.utils.spec.ts new file mode 100644 index 0000000000..66fac8e410 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/__tests__/logic-function-graphql-api-exception-handler.utils.spec.ts @@ -0,0 +1,62 @@ +import { + ForbiddenError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; +import { logicFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils'; + +describe('logicFunctionGraphQLApiExceptionHandler', () => { + it('should map LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED to UserInputError', () => { + expect(() => + logicFunctionGraphQLApiExceptionHandler( + new LogicFunctionException( + 'dependencies too large', + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED, + ), + ), + ).toThrow(UserInputError); + }); + + it('should map LOGIC_FUNCTION_NOT_FOUND to NotFoundError', () => { + expect(() => + logicFunctionGraphQLApiExceptionHandler( + new LogicFunctionException( + 'not found', + LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ), + ), + ).toThrow(NotFoundError); + }); + + it('should map LOGIC_FUNCTION_DISABLED to ForbiddenError', () => { + expect(() => + logicFunctionGraphQLApiExceptionHandler( + new LogicFunctionException( + 'disabled', + LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED, + ), + ), + ).toThrow(ForbiddenError); + }); + + it('should rethrow LOGIC_FUNCTION_LAYER_BUILD_FAILED unchanged', () => { + const exception = new LogicFunctionException( + 'layer build failed', + LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED, + ); + + expect(() => logicFunctionGraphQLApiExceptionHandler(exception)).toThrow( + exception, + ); + }); + + it('should rethrow unknown errors unchanged', () => { + const error = new Error('unrelated'); + + expect(() => logicFunctionGraphQLApiExceptionHandler(error)).toThrow(error); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils.ts index 9318d69161..47d4ca7cbd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils.ts @@ -33,6 +33,7 @@ export const logicFunctionGraphQLApiExceptionHandler = (error: any) => { case LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED: throw error; case LogicFunctionExceptionCode.LOGIC_FUNCTION_COMPILATION_FAILED: + case LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED: case LogicFunctionExceptionCode.INVALID_LOGIC_FUNCTION_INPUT: throw new UserInputError(error); case LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED: diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/utils/__tests__/logic-function-dependencies-size-graphql-api-exception-handler.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/utils/__tests__/logic-function-dependencies-size-graphql-api-exception-handler.util.spec.ts new file mode 100644 index 0000000000..261fac874a --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/utils/__tests__/logic-function-dependencies-size-graphql-api-exception-handler.util.spec.ts @@ -0,0 +1,43 @@ +import { BaseGraphQLError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; +import { logicFunctionDependenciesSizeGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/logic-function-dependencies-size-graphql-api-exception-handler.util'; + +describe('logicFunctionDependenciesSizeGraphqlApiExceptionHandler', () => { + it('should throw a metadata validation error carrying one logicFunction entry', () => { + const exception = new LogicFunctionException( + "Dependency layer 'deps-abc' exceeds the Lambda layer size limit", + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED, + ); + + let thrown: BaseGraphQLError | undefined; + + try { + logicFunctionDependenciesSizeGraphqlApiExceptionHandler(exception); + } catch (error) { + thrown = error as BaseGraphQLError; + } + + expect(thrown).toBeInstanceOf(BaseGraphQLError); + + const extensions = thrown?.extensions as { + summary: { totalErrors: number; logicFunction?: number }; + errors: { + logicFunction?: { + errors: { code: string; value?: unknown }[]; + }[]; + }; + }; + + expect(extensions.summary).toEqual({ totalErrors: 1, logicFunction: 1 }); + expect(extensions.errors.logicFunction).toHaveLength(1); + expect(extensions.errors.logicFunction?.[0].errors[0].code).toBe( + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED, + ); + expect(extensions.errors.logicFunction?.[0].errors[0].value).toBe( + exception.message, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/utils/logic-function-dependencies-size-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/utils/logic-function-dependencies-size-graphql-api-exception-handler.util.ts new file mode 100644 index 0000000000..30b9e1774c --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/utils/logic-function-dependencies-size-graphql-api-exception-handler.util.ts @@ -0,0 +1,43 @@ +import { + BaseGraphQLError, + ErrorCode, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { type LogicFunctionException } from 'src/engine/metadata-modules/logic-function/logic-function.exception'; +import { type MetadataValidationErrorResponseDescriptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/types/metadata-validation-error-response-descriptor.type'; + +export const logicFunctionDependenciesSizeGraphqlApiExceptionHandler = ( + exception: LogicFunctionException, +) => { + const payload: MetadataValidationErrorResponseDescriptor = { + summary: { totalErrors: 1, logicFunction: 1 }, + errors: { + logicFunction: [ + { + type: 'update', + metadataName: 'logicFunction', + errors: [ + { + code: exception.code, + message: + 'Production dependencies are too large to install. Move packages that are not imported by your logic functions (UI libraries, dev tooling) out of "dependencies".', + userFriendlyMessage: exception.userFriendlyMessage, + value: exception.message, + }, + ], + flatEntityMinimalInformation: {}, + }, + ], + }, + }; + + throw new BaseGraphQLError( + exception.message, + ErrorCode.METADATA_VALIDATION_FAILED, + { + code: 'METADATA_VALIDATION_ERROR', + ...payload, + userFriendlyMessage: exception.userFriendlyMessage, + message: 'Validation failed for 1 logicFunction', + }, + ); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor.ts index d14332bcf9..9f3cedbdcf 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor.ts @@ -12,7 +12,12 @@ import { FlatEntityMapsException, FlatEntityMapsExceptionCode, } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +import { logicFunctionDependenciesSizeGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/logic-function-dependencies-size-graphql-api-exception-handler.util'; import { workspaceMigrationBuilderGraphqlApiExceptionHandler } from 'src/engine/workspace-manager/workspace-migration/interceptors/utils/workspace-migration-builder-graphql-api-exception-handler.util'; import { workspaceMigrationRunnerExceptionFormatter } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-runner-exception-formatter'; import { WorkspaceMigrationRunnerException } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception'; @@ -37,6 +42,14 @@ export class WorkspaceMigrationGraphqlApiExceptionInterceptor implements NestInt workspaceMigrationBuilderGraphqlApiExceptionHandler(error); } + if ( + error instanceof LogicFunctionException && + error.code === + LogicFunctionExceptionCode.LOGIC_FUNCTION_DEPENDENCIES_SIZE_EXCEEDED + ) { + logicFunctionDependenciesSizeGraphqlApiExceptionHandler(error); + } + if (error instanceof WorkspaceMigrationRunnerException) { workspaceMigrationRunnerExceptionFormatter(error); }