App uninstall lambda, layers cleanup (#21749)

# 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-<wsId>-<appId>` (all versions) | per-app |
**leaked** | **deleted** (driver `deleteApplicationResources` →
`deleteSdkLayer`) |
| Deps layer `deps-<checksum>` | 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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21749?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:
Paul Rastoin
2026-06-19 15:10:39 +02:00
committed by GitHub
parent a59ed80422
commit 98c35ea804
7 changed files with 170 additions and 35 deletions
@@ -14,6 +14,10 @@ export class DisabledDriver implements LogicFunctionDriver {
// No-op when disabled
}
async deleteApplicationResources(): Promise<void> {
// No-op when disabled
}
async execute(): Promise<LogicFunctionExecuteResult> {
throw new LogicFunctionException(
'Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable.',
@@ -95,6 +95,19 @@ export class LambdaDriver implements LogicFunctionDriver {
await this.executorManager.delete(flatLogicFunction);
}
async deleteApplicationResources({
workspaceId,
applicationUniversalIdentifier,
}: {
workspaceId: string;
applicationUniversalIdentifier: string;
}): Promise<void> {
await this.layerManager.deleteSdkLayer({
workspaceId,
applicationUniversalIdentifier,
});
}
async installPrebuiltBundle(
params: LogicFunctionInstallPrebuiltBundleParams,
): Promise<void> {
@@ -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<LambdaDriverOptions, 'layerBucket'>,
private readonly awsClient: LambdaAwsClientService,
@@ -96,6 +100,21 @@ export class LambdaLayerManagerService {
return arn;
}
async deleteSdkLayer({
workspaceId,
applicationUniversalIdentifier,
}: {
workspaceId: string;
applicationUniversalIdentifier: string;
}): Promise<void> {
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;
@@ -90,6 +90,8 @@ export class LocalDriver implements LogicFunctionDriver {
async delete(): Promise<void> {}
async deleteApplicationResources(): Promise<void> {}
async installPrebuiltBundle(
params: LogicFunctionInstallPrebuiltBundleParams,
): Promise<void> {
@@ -43,8 +43,16 @@ export type LogicFunctionTranspileResult = {
builtCode: string;
};
export type LogicFunctionDeleteApplicationResourcesParams = {
workspaceId: string;
applicationUniversalIdentifier: string;
};
export interface LogicFunctionDriver {
delete(flatLogicFunction: FlatLogicFunction): Promise<void>;
deleteApplicationResources(
params: LogicFunctionDeleteApplicationResourcesParams,
): Promise<void>;
execute(
params: LogicFunctionExecuteParams,
): Promise<LogicFunctionExecuteResult>;