From 98c35ea80461b0781b0dc7e8b8688059c83ac50f Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:10:39 +0200 Subject: [PATCH] App uninstall lambda, layers cleanup (#21749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Introduction On a logic function deletion also remove the driver entry On a app uninstall also remove the sdk layer ( keeps the dep one as it can be shared across several lambda ) | Resource | Scope | Before this PR | After | |---|---|---|---| | DB metadata (functions, objects, fields…) | per-app | deleted | deleted | | Source folder (`FileFolder.Source`) | per-function | deleted | deleted | | Built handler file (`FileFolder.BuiltLogicFunction`) | per-function | deleted | deleted | | **Lambda function** | per-function | **leaked** | **deleted** (driver `delete`) | | **SDK layer** `sdk--` (all versions) | per-app | **leaked** | **deleted** (driver `deleteApplicationResources` → `deleteSdkLayer`) | | Deps layer `deps-` | shared across apps/workspaces | not deleted | **intentionally not deleted** (content-addressed, GC'd) | ## What I don't like about all that Right now there's some non reversible side effect inside the workspace migration transaction - If the transaction fails we're facing data loss - It also slows down everything I'm about to create a new PR that allow population post transaction commit side effect / cleanup to be run later Review in cubic --- .../application-sync.service.ts | 38 ++++++++-- .../drivers/disabled.driver.ts | 4 ++ .../drivers/lambda.driver.ts | 13 ++++ .../services/lambda-layer-manager.service.ts | 71 ++++++++++++++----- .../drivers/local.driver.ts | 2 + .../logic-function-driver.interface.ts | 8 +++ ...e-logic-function-action-handler.service.ts | 69 ++++++++++++++---- 7 files changed, 170 insertions(+), 35 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts index 37adddc1a9..99887f63a0 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { type Manifest } from 'twenty-shared/application'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; @@ -6,17 +6,19 @@ import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { PackageJson } from 'type-fest'; +import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service'; +import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util'; +import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationException, ApplicationExceptionCode, } from 'src/engine/core-modules/application/application.exception'; -import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; -import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util'; -import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-driver-factory.token'; +import { type LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory'; import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -35,6 +37,8 @@ export class ApplicationSyncService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceCacheService: WorkspaceCacheService, private readonly fileStorageService: FileStorageService, + @Inject(LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN) + private readonly logicFunctionDriverFactory: LogicFunctionDriverFactory, ) {} public async synchronizeFromManifest({ @@ -247,6 +251,32 @@ export class ApplicationSyncService { workspaceId, ); + await this.cleanupApplicationRuntimeResources({ + workspaceId, + applicationUniversalIdentifier, + }); + return validateAndBuildResult.workspaceMigration; } + + private async cleanupApplicationRuntimeResources({ + workspaceId, + applicationUniversalIdentifier, + }: { + workspaceId: string; + applicationUniversalIdentifier: string; + }): Promise { + try { + const driver = this.logicFunctionDriverFactory.getCurrentDriver(); + + await driver.deleteApplicationResources({ + workspaceId, + applicationUniversalIdentifier, + }); + } catch (error) { + this.logger.warn( + `Failed to clean up runtime resources for application ${applicationUniversalIdentifier} in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/disabled.driver.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/disabled.driver.ts index 6edeb52175..2a860f7123 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/disabled.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/disabled.driver.ts @@ -14,6 +14,10 @@ export class DisabledDriver implements LogicFunctionDriver { // No-op when disabled } + async deleteApplicationResources(): Promise { + // No-op when disabled + } + async execute(): Promise { throw new LogicFunctionException( 'Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable.', diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts index 190123899c..4e0e7f663f 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts @@ -95,6 +95,19 @@ export class LambdaDriver implements LogicFunctionDriver { await this.executorManager.delete(flatLogicFunction); } + async deleteApplicationResources({ + workspaceId, + applicationUniversalIdentifier, + }: { + workspaceId: string; + applicationUniversalIdentifier: string; + }): Promise { + await this.layerManager.deleteSdkLayer({ + workspaceId, + applicationUniversalIdentifier, + }); + } + async installPrebuiltBundle( params: LogicFunctionInstallPrebuiltBundleParams, ): Promise { 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 b346aeea7c..9487055200 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 @@ -5,14 +5,16 @@ import { type GetFunctionCommandOutput, ListLayerVersionsCommand, PublishLayerVersionCommand, + ResourceNotFoundException, } from '@aws-sdk/client-lambda'; +import { Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { SDK_LAYER_PREFIX_IN_ZIP } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/constants/lambda-driver.constant'; -import { type LambdaDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/types/lambda-driver.type'; import { type LambdaAwsClientService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-aws-client.service'; import { type LambdaToolFunctionsService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-tool-functions.service'; +import { type LambdaDriverOptions } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/types/lambda-driver.type'; import { getLambdaDepsLayerName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/get-lambda-deps-layer-name.util'; import { getLambdaSdkLayerName } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/get-lambda-sdk-layer-name.util'; import { reprefixLambdaZipEntries } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/reprefix-lambda-zip-entries.util'; @@ -27,6 +29,8 @@ type LayerAppContext = { }; export class LambdaLayerManagerService { + private readonly logger = new Logger(LambdaLayerManagerService.name); + constructor( private readonly options: Pick, private readonly awsClient: LambdaAwsClientService, @@ -96,6 +100,21 @@ export class LambdaLayerManagerService { return arn; } + async deleteSdkLayer({ + workspaceId, + applicationUniversalIdentifier, + }: { + workspaceId: string; + applicationUniversalIdentifier: string; + }): Promise { + const layerName = getLambdaSdkLayerName({ + workspaceId, + applicationUniversalIdentifier, + }); + + await this.deleteAllLayerVersions(layerName); + } + hasExpectedLayers({ lambdaExecutor, flatApplication, @@ -226,25 +245,45 @@ export class LambdaLayerManagerService { let marker: string | undefined; do { - const listResult = await lambdaClient.send( - new ListLayerVersionsCommand({ - LayerName: layerName, - MaxItems: 50, - Marker: marker, - }), - ); + let listResult; + + try { + listResult = await lambdaClient.send( + new ListLayerVersionsCommand({ + LayerName: layerName, + MaxItems: 50, + Marker: marker, + }), + ); + } catch (error) { + // Layer never existed or already fully removed. Idempotent. + if (error instanceof ResourceNotFoundException) { + return; + } + + throw error; + } const versions = listResult.LayerVersions ?? []; await Promise.all( - versions.map((version) => - lambdaClient.send( - new DeleteLayerVersionCommand({ - LayerName: layerName, - VersionNumber: version.Version, - }), - ), - ), + versions.map(async (version) => { + try { + await lambdaClient.send( + new DeleteLayerVersionCommand({ + LayerName: layerName, + VersionNumber: version.Version, + }), + ); + } catch (error) { + // Already gone: another concurrent cleanup removed it. Idempotent. + if (error instanceof ResourceNotFoundException) { + return; + } + + throw error; + } + }), ); marker = listResult.NextMarker; diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver.ts index 16fc078c84..2ca7b2d839 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/local.driver.ts @@ -90,6 +90,8 @@ export class LocalDriver implements LogicFunctionDriver { async delete(): Promise {} + async deleteApplicationResources(): Promise {} + async installPrebuiltBundle( params: LogicFunctionInstallPrebuiltBundleParams, ): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface.ts index 10b2094ced..72640f0c46 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface.ts @@ -43,8 +43,16 @@ export type LogicFunctionTranspileResult = { builtCode: string; }; +export type LogicFunctionDeleteApplicationResourcesParams = { + workspaceId: string; + applicationUniversalIdentifier: string; +}; + export interface LogicFunctionDriver { delete(flatLogicFunction: FlatLogicFunction): Promise; + deleteApplicationResources( + params: LogicFunctionDeleteApplicationResourcesParams, + ): Promise; execute( params: LogicFunctionExecuteParams, ): Promise; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts index b5365f475c..b25da9ebf1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts @@ -1,12 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { FileFolder } from 'twenty-shared/types'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-driver-factory.token'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; + +import type { LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory'; +import { getLogicFunctionSubfolderForFromSource } from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-subfolder-for-from-source'; import { FlatDeleteLogicFunctionAction, UniversalDeleteLogicFunctionAction, @@ -15,14 +19,17 @@ import { WorkspaceMigrationActionRunnerArgs, WorkspaceMigrationActionRunnerContext, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; -import { getLogicFunctionSubfolderForFromSource } from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-subfolder-for-from-source'; @Injectable() export class DeleteLogicFunctionActionHandlerService extends WorkspaceMigrationRunnerActionHandler( 'delete', 'logicFunction', ) { - constructor(private readonly fileStorageService: FileStorageService) { + constructor( + private readonly fileStorageService: FileStorageService, + @Inject(LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN) + private readonly logicFunctionDriverFactory: LogicFunctionDriverFactory, + ) { super(); } @@ -60,19 +67,51 @@ export class DeleteLogicFunctionActionHandlerService extends WorkspaceMigrationR const applicationUniversalIdentifier = flatApplication.universalIdentifier; - await this.fileStorageService.deleteFolder({ - workspaceId, - applicationUniversalIdentifier, - fileFolder: FileFolder.Source, - folderPath: getLogicFunctionSubfolderForFromSource(flatLogicFunction.id), - }); + await Promise.all([ + this.deleteBestEffort( + `source folder for logic function ${flatLogicFunction.id}`, + () => + this.fileStorageService.deleteFolder({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.Source, + folderPath: getLogicFunctionSubfolderForFromSource( + flatLogicFunction.id, + ), + }), + ), + this.deleteBestEffort( + `built handler for logic function ${flatLogicFunction.id}`, + () => + this.fileStorageService.deleteFile({ + workspaceId, + applicationUniversalIdentifier, + fileFolder: FileFolder.BuiltLogicFunction, + resourcePath: flatLogicFunction.builtHandlerPath, + }), + ), + this.deleteBestEffort( + `runtime resource for logic function ${flatLogicFunction.id}`, + () => + this.logicFunctionDriverFactory + .getCurrentDriver() + .delete(flatLogicFunction), + ), + ]); + } - await this.fileStorageService.deleteFile({ - workspaceId, - applicationUniversalIdentifier, - fileFolder: FileFolder.BuiltLogicFunction, - resourcePath: flatLogicFunction.builtHandlerPath, - }); + private async deleteBestEffort( + description: string, + operation: () => Promise, + ): Promise { + try { + await operation(); + } catch (error) { + this.logger.warn( + `Failed to delete ${description}: ${error instanceof Error ? error.message : String(error)}`, + DeleteLogicFunctionActionHandlerService.name, + ); + } } async rollbackForMetadata(): Promise {}