feat(logic-function): add LIVE / PREBUILT execution modes (#20873)
## Summary
### Why
1. Sending the code to the lambda (~1Mb usually) is heavy on network and
results to a constant traffic of ~30Mb/s on AWS which results into TB of
network data every month
2. eval(1MB of code) is not that fast, it's heavy on memory and CPU on
lambda side
### High level
Adds two execution modes for logic functions, gated behind the new
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` workspace feature flag (off
everywhere by default):
- **LIVE** (current behavior, preserved bit-for-bit): the compiled
bundle is read from object storage and shipped in every Lambda invoke
payload. Used for fast iteration in the workflow editor / Settings test
runs.
- **PREBUILT** (new): the bundle is installed onto the per-function
Lambda alongside the unified executor, and invocations carry only `{
params, env, handlerName }` — saving JSON payload egress and warm-start
`import()` cost on every call.
### Key design choices
- **Unified Lambda handler** (`constants/executor/index.mjs`) dispatches
at runtime: `event.code` present ? LIVE (write to `/tmp`, dynamic
import) : `import('./prebuilt-logic-function.mjs')`. Both code paths
always coexist on the deployment package, so the same Lambda can serve
either mode without redeploying.
- **Install runs inside the `validateBuildAndRun` migration pipeline**,
not at execute time. `Create/UpdateLogicFunctionActionHandlerService`
calls `driver.installPrebuiltBundle` when `executionMode` flips
LIVE?PREBUILT or `checksum` changes while PREBUILT, gated on
`isBuildUpToDate=true` and a fresh checksum.
- **Strict execute, no reconciliation**:
`LogicFunctionExecutorService.execute` resolves `effectiveExecutionMode`
(caller override > feature flag > entity column). For PREBUILT it asks
the driver `getInstalledBundleChecksum` (Lambda `twenty:bundle-checksum`
tag for AWS, sidecar file locally) and throws
`LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED` on mismatch.
- **Feature flag gates every side effect**: with the flag off the
executor forces LIVE, the action-handler install hooks bail before AWS,
and workflow activation does not flip the mode. Rollback is just turning
the flag off.
### Lifecycle
- New workflow CODE step ? `LIVE`, no install.
- Workflow activated ? build + activation flips `executionMode=PREBUILT`
? action-handler installs the bundle + sets the Lambda tag.
- Draft from active version ? duplicated logic function reset to `LIVE`.
- App install ? manifest converter sets `PREBUILT`, create-action
handler installs.
- Test runs (`executeOneFromSource`, workflow editor) pass
`executionMode=LIVE` explicitly.
### Observability
`[lambda-timing]` log lines now include `effectiveExecutionMode` and
`payloadBytes`; the action handler logs `install_duration_ms` for each
install.
## Test plan
- [x] `npx nx typecheck twenty-server` ? passes
- [x] `npx oxlint --type-aware` on all changed files ? 0 warnings, 0
errors
- [x] `npx nx test twenty-server` ? 588 suites / 5009 tests pass (no
regressions vs main)
- [x] New unit suite `flat-logic-function-validator.service.spec.ts` ?
9/9
- [x] Existing
`workflow-version-step-operations.workspace-service.spec.ts` ? 8/8
(verified the new token-based DI avoids a circular-import regression)
- [x] Snapshot for
`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY` updated
to include `executionMode`
- [x] Integration suite `logic-function-execution.integration-spec.ts`
extended to assert `executionMode=LIVE` on newly-created functions and
continues to exercise the LIVE happy path
- [ ] Manual staging rollout: flip
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` per workspace, observe
`[lambda-timing]` `payloadBytes` drop + `install_duration_ms`, then ramp
in prod.
This commit is contained in:
+30
-25
@@ -4,6 +4,29 @@ import { createHash } from 'crypto';
|
||||
export const handler = async (event) => {
|
||||
const { code, params, env, handlerName } = event;
|
||||
|
||||
// oxlint-disable-next-line no-undef
|
||||
const oldProcessEnv = { ...process.env };
|
||||
|
||||
try {
|
||||
// oxlint-disable-next-line no-undef
|
||||
process.env = { ...process.env, ...(env ?? {}) };
|
||||
|
||||
const mainFile = code
|
||||
? await loadFromPayload(code)
|
||||
: await import('./prebuilt-logic-function.mjs');
|
||||
|
||||
const handlerFn = handlerName
|
||||
.split('.')
|
||||
.reduce((obj, key) => obj[key], mainFile);
|
||||
|
||||
return await handlerFn(params);
|
||||
} finally {
|
||||
// oxlint-disable-next-line no-undef
|
||||
process.env = oldProcessEnv;
|
||||
}
|
||||
};
|
||||
|
||||
const loadFromPayload = async (code) => {
|
||||
// Use a content-hash filename so identical code is written once per
|
||||
// warm container and Node's ESM module cache can short-circuit subsequent
|
||||
// `import(mainPath)` calls. With random names, every warm invocation
|
||||
@@ -23,31 +46,13 @@ export const handler = async (event) => {
|
||||
const codeHash = createHash('sha256').update(code).digest('hex');
|
||||
const mainPath = `/tmp/${codeHash}.mjs`;
|
||||
|
||||
// oxlint-disable-next-line no-undef
|
||||
const oldProcessEnv = { ...process.env };
|
||||
|
||||
try {
|
||||
try {
|
||||
await fs.access(mainPath);
|
||||
} catch {
|
||||
await fs.writeFile(mainPath, code, 'utf8');
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-undef
|
||||
process.env = { ...process.env, ...(env ?? {}) };
|
||||
|
||||
const mainFile = await import(mainPath);
|
||||
|
||||
const handlerFn = handlerName
|
||||
.split('.')
|
||||
.reduce((obj, key) => obj[key], mainFile);
|
||||
|
||||
return await handlerFn(params);
|
||||
} finally {
|
||||
// Intentionally NOT removing `mainPath` here so subsequent warm
|
||||
// invocations hit Node's ESM cache (see comment above).
|
||||
|
||||
// oxlint-disable-next-line no-undef
|
||||
process.env = oldProcessEnv;
|
||||
await fs.access(mainPath);
|
||||
} catch {
|
||||
await fs.writeFile(mainPath, code, 'utf8');
|
||||
}
|
||||
|
||||
// Intentionally NOT removing `mainPath` after import so subsequent warm
|
||||
// invocations hit Node's ESM cache (see comment above).
|
||||
return import(mainPath);
|
||||
};
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN =
|
||||
'LOGIC_FUNCTION_DRIVER_FACTORY';
|
||||
+11
@@ -27,4 +27,15 @@ export class DisabledDriver implements LogicFunctionDriver {
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
async installPrebuiltBundle(): Promise<void> {
|
||||
throw new LogicFunctionException(
|
||||
'Logic function prebuilt install is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable.',
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
async getInstalledBundleChecksum(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+152
-17
@@ -18,6 +18,8 @@ import {
|
||||
LogType,
|
||||
PublishLayerVersionCommand,
|
||||
ResourceNotFoundException,
|
||||
TagResourceCommand,
|
||||
UpdateFunctionCodeCommand,
|
||||
UpdateFunctionConfigurationCommand,
|
||||
waitUntilFunctionActiveV2,
|
||||
waitUntilFunctionUpdatedV2,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
type LogicFunctionDriver,
|
||||
type LogicFunctionExecuteParams,
|
||||
type LogicFunctionExecuteResult,
|
||||
type LogicFunctionInstallPrebuiltBundleParams,
|
||||
type LogicFunctionTranspileParams,
|
||||
type LogicFunctionTranspileResult,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
|
||||
@@ -50,12 +53,16 @@ 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 { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
|
||||
import { LogicFunctionRuntime } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import {
|
||||
LogicFunctionExecutionMode,
|
||||
LogicFunctionRuntime,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
|
||||
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
import { isLogicFunctionReadyForPrebuiltInstall } from 'src/engine/metadata-modules/logic-function/utils/is-logic-function-ready-for-prebuilt-install.util';
|
||||
|
||||
enum LambdaExecutionPhase {
|
||||
BUILD = 'build',
|
||||
@@ -88,8 +95,16 @@ const BUILDER_HANDLER_PATH = resolve(
|
||||
),
|
||||
);
|
||||
|
||||
const LAMBDA_PREBUILT_BUNDLE_CHECKSUM_TAG = 'twenty:bundle-checksum';
|
||||
|
||||
const PREBUILT_BUNDLE_FILE_NAME = 'prebuilt-logic-function.mjs';
|
||||
|
||||
const PREBUILT_INSTALL_LOCK_TTL_MS = 180_000;
|
||||
const PREBUILT_INSTALL_LOCK_RETRY_MS = 1_000;
|
||||
const PREBUILT_INSTALL_LOCK_MAX_RETRIES = 180;
|
||||
|
||||
type LambdaDriverExecutorPayload = {
|
||||
code: string;
|
||||
code?: string;
|
||||
params: object;
|
||||
env: Record<string, string>;
|
||||
handlerName: string;
|
||||
@@ -1164,7 +1179,20 @@ export class LambdaDriver implements LogicFunctionDriver {
|
||||
payload,
|
||||
env,
|
||||
timeoutMs = 900_000,
|
||||
forceExecutionMode,
|
||||
}: LogicFunctionExecuteParams): Promise<LogicFunctionExecuteResult> {
|
||||
const executionMode = forceExecutionMode ?? flatLogicFunction.executionMode;
|
||||
|
||||
if (
|
||||
executionMode === LogicFunctionExecutionMode.PREBUILT &&
|
||||
!isLogicFunctionReadyForPrebuiltInstall(flatLogicFunction)
|
||||
) {
|
||||
throw new LogicFunctionException(
|
||||
`Cannot run logic function '${flatLogicFunction.id}' in PREBUILT mode: bundle is not installed`,
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
|
||||
);
|
||||
}
|
||||
|
||||
let currentPhase: LambdaExecutionPhase = LambdaExecutionPhase.BUILD;
|
||||
let buildExecutorMs = 0;
|
||||
let getBuiltCodeMs = 0;
|
||||
@@ -1180,26 +1208,28 @@ export class LambdaDriver implements LogicFunctionDriver {
|
||||
buildExecutorMs = Date.now() - buildStart;
|
||||
|
||||
currentPhase = LambdaExecutionPhase.FETCH_CODE;
|
||||
const fetchStart = Date.now();
|
||||
|
||||
const compiledCode = await this.logicFunctionResourceService.getBuiltCode(
|
||||
{
|
||||
workspaceId: flatLogicFunction.workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
builtHandlerPath: flatLogicFunction.builtHandlerPath,
|
||||
},
|
||||
);
|
||||
getBuiltCodeMs = Date.now() - fetchStart;
|
||||
|
||||
currentPhase = LambdaExecutionPhase.INVOKE;
|
||||
const invokeFlowStart = Date.now();
|
||||
|
||||
const executorPayload: LambdaDriverExecutorPayload = {
|
||||
params: payload,
|
||||
code: compiledCode,
|
||||
env: env ?? {},
|
||||
handlerName: flatLogicFunction.handlerName,
|
||||
};
|
||||
|
||||
if (executionMode === LogicFunctionExecutionMode.LIVE) {
|
||||
const fetchStart = Date.now();
|
||||
|
||||
executorPayload.code =
|
||||
await this.logicFunctionResourceService.getBuiltCode({
|
||||
workspaceId: flatLogicFunction.workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
builtHandlerPath: flatLogicFunction.builtHandlerPath,
|
||||
});
|
||||
getBuiltCodeMs = Date.now() - fetchStart;
|
||||
}
|
||||
|
||||
currentPhase = LambdaExecutionPhase.INVOKE;
|
||||
|
||||
const payloadString = JSON.stringify(executorPayload);
|
||||
const params: InvokeCommandInput = {
|
||||
FunctionName: flatLogicFunction.id,
|
||||
@@ -1228,10 +1258,10 @@ export class LambdaDriver implements LogicFunctionDriver {
|
||||
coldStart,
|
||||
} = this.parseLambdaLogResult(result.LogResult);
|
||||
|
||||
const duration = Date.now() - fetchStart;
|
||||
const duration = Date.now() - invokeFlowStart;
|
||||
|
||||
this.logger.log(
|
||||
`[lambda-timing] fnId=${flatLogicFunction.id} totalMs=${Date.now() - buildStart} buildExecutorMs=${buildExecutorMs} getBuiltCodeMs=${getBuiltCodeMs} payloadBytes=${Buffer.byteLength(payloadString, 'utf8')} invokeSendMs=${invokeSendMs} reportDurationMs=${reportDurationMs ?? 'n/a'} billedMs=${billedDurationMs ?? 'n/a'} initDurationMs=${initDurationMs ?? 'n/a'} coldStart=${coldStart}`,
|
||||
`[lambda-timing] fnId=${flatLogicFunction.id} executionMode=${executionMode} totalMs=${Date.now() - buildStart} buildExecutorMs=${buildExecutorMs} getBuiltCodeMs=${getBuiltCodeMs} payloadBytes=${Buffer.byteLength(payloadString, 'utf8')} invokeSendMs=${invokeSendMs} reportDurationMs=${reportDurationMs ?? 'n/a'} billedMs=${billedDurationMs ?? 'n/a'} initDurationMs=${initDurationMs ?? 'n/a'} coldStart=${coldStart}`,
|
||||
);
|
||||
|
||||
if (result.FunctionError) {
|
||||
@@ -1287,4 +1317,109 @@ export class LambdaDriver implements LogicFunctionDriver {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getPrebuiltInstallLockKey(flatLogicFunction: FlatLogicFunction) {
|
||||
return `lambda-install:${flatLogicFunction.id}`;
|
||||
}
|
||||
|
||||
async installPrebuiltBundle({
|
||||
flatLogicFunction,
|
||||
flatApplication,
|
||||
applicationUniversalIdentifier,
|
||||
}: LogicFunctionInstallPrebuiltBundleParams): Promise<void> {
|
||||
if (!isNonEmptyString(flatLogicFunction.checksum)) {
|
||||
throw new LogicFunctionException(
|
||||
`Cannot install prebuilt bundle for function '${flatLogicFunction.id}' without a checksum`,
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
|
||||
);
|
||||
}
|
||||
|
||||
const checksum = flatLogicFunction.checksum;
|
||||
|
||||
await this.buildLambdaExecutor({
|
||||
flatLogicFunction,
|
||||
flatApplication,
|
||||
applicationUniversalIdentifier,
|
||||
});
|
||||
|
||||
await this.cacheLockService.withLock(
|
||||
async () => {
|
||||
const compiledCode =
|
||||
await this.logicFunctionResourceService.getBuiltCode({
|
||||
workspaceId: flatLogicFunction.workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
builtHandlerPath: flatLogicFunction.builtHandlerPath,
|
||||
});
|
||||
|
||||
const temporaryDirManager = new TemporaryDirManager();
|
||||
const { sourceTemporaryDir, lambdaZipPath } =
|
||||
await temporaryDirManager.init();
|
||||
|
||||
try {
|
||||
await copyExecutor(sourceTemporaryDir);
|
||||
await fs.writeFile(
|
||||
join(sourceTemporaryDir, PREBUILT_BUNDLE_FILE_NAME),
|
||||
compiledCode,
|
||||
'utf8',
|
||||
);
|
||||
await createZipFile(sourceTemporaryDir, lambdaZipPath);
|
||||
|
||||
const lambdaClient = await this.getLambdaClient();
|
||||
|
||||
const updateResult = await lambdaClient.send(
|
||||
new UpdateFunctionCodeCommand({
|
||||
FunctionName: flatLogicFunction.id,
|
||||
ZipFile: await fs.readFile(lambdaZipPath),
|
||||
}),
|
||||
);
|
||||
|
||||
await this.waitFunctionUpdated(flatLogicFunction.id);
|
||||
|
||||
const functionArn = updateResult.FunctionArn;
|
||||
|
||||
if (!isNonEmptyString(functionArn)) {
|
||||
throw new LogicFunctionException(
|
||||
`UpdateFunctionCode did not return a FunctionArn for '${flatLogicFunction.id}'`,
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
|
||||
);
|
||||
}
|
||||
|
||||
await lambdaClient.send(
|
||||
new TagResourceCommand({
|
||||
Resource: functionArn,
|
||||
Tags: {
|
||||
[LAMBDA_PREBUILT_BUNDLE_CHECKSUM_TAG]: checksum,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to install prebuilt bundle for function ${flatLogicFunction.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await temporaryDirManager.clean();
|
||||
}
|
||||
},
|
||||
this.getPrebuiltInstallLockKey(flatLogicFunction),
|
||||
{
|
||||
ttl: PREBUILT_INSTALL_LOCK_TTL_MS,
|
||||
ms: PREBUILT_INSTALL_LOCK_RETRY_MS,
|
||||
maxRetries: PREBUILT_INSTALL_LOCK_MAX_RETRIES,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getInstalledBundleChecksum(
|
||||
flatLogicFunction: FlatLogicFunction,
|
||||
): Promise<string | null> {
|
||||
const lambdaExecutor = await this.getLambdaExecutor(flatLogicFunction);
|
||||
|
||||
if (!isDefined(lambdaExecutor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return lambdaExecutor.Tags?.[LAMBDA_PREBUILT_BUNDLE_CHECKSUM_TAG] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
+152
-8
@@ -9,10 +9,12 @@ import {
|
||||
type LogicFunctionExecuteParams,
|
||||
type LogicFunctionExecuteResult,
|
||||
type LogicFunctionDriver,
|
||||
type LogicFunctionInstallPrebuiltBundleParams,
|
||||
type LogicFunctionTranspileParams,
|
||||
type LogicFunctionTranspileResult,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { type CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
|
||||
import { LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-executor-tmpdir-folder';
|
||||
@@ -20,6 +22,13 @@ import { ConsoleListener } from 'src/engine/core-modules/logic-function/logic-fu
|
||||
import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/temporary-dir-manager';
|
||||
import { HANDLER_NAME_REGEX } from 'src/engine/metadata-modules/logic-function/constants/handler.contant';
|
||||
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
|
||||
import { LogicFunctionExecutionMode } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
|
||||
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
import { isLogicFunctionReadyForPrebuiltInstall } from 'src/engine/metadata-modules/logic-function/utils/is-logic-function-ready-for-prebuilt-install.util';
|
||||
import { copyYarnEngineAndBuildDependencies } from 'src/engine/core-modules/application/application-package/utils/copy-yarn-engine-and-build-dependencies';
|
||||
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';
|
||||
@@ -30,6 +39,13 @@ const LAYER_BUILD_LOCK_RETRY_MS = 500;
|
||||
const LAYER_BUILD_LOCK_MAX_RETRIES = 240;
|
||||
const LAYER_BUILD_READY_SENTINEL = '.twenty-layer-ready';
|
||||
|
||||
const PREBUILT_BUNDLE_FILE_NAME = 'prebuilt-logic-function.mjs';
|
||||
const PREBUILT_CHECKSUM_FILE_NAME = 'prebuilt-bundle.checksum';
|
||||
|
||||
const PREBUILT_INSTALL_LOCK_TTL_MS = 180_000;
|
||||
const PREBUILT_INSTALL_LOCK_RETRY_MS = 1_000;
|
||||
const PREBUILT_INSTALL_LOCK_MAX_RETRIES = 180;
|
||||
|
||||
export interface LocalDriverOptions {
|
||||
logicFunctionResourceService: LogicFunctionResourceService;
|
||||
sdkClientArchiveService: SdkClientArchiveService;
|
||||
@@ -283,7 +299,20 @@ export class LocalDriver implements LogicFunctionDriver {
|
||||
payload,
|
||||
env,
|
||||
timeoutMs = 900_000,
|
||||
forceExecutionMode,
|
||||
}: LogicFunctionExecuteParams): Promise<LogicFunctionExecuteResult> {
|
||||
const executionMode = forceExecutionMode ?? flatLogicFunction.executionMode;
|
||||
|
||||
if (
|
||||
executionMode === LogicFunctionExecutionMode.PREBUILT &&
|
||||
!isLogicFunctionReadyForPrebuiltInstall(flatLogicFunction)
|
||||
) {
|
||||
throw new LogicFunctionException(
|
||||
`Cannot run logic function '${flatLogicFunction.id}' in PREBUILT mode: bundle is not installed`,
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
|
||||
);
|
||||
}
|
||||
|
||||
await this.createLayerIfNotExist({
|
||||
flatApplication,
|
||||
applicationUniversalIdentifier,
|
||||
@@ -300,20 +329,25 @@ export class LocalDriver implements LogicFunctionDriver {
|
||||
try {
|
||||
const { sourceTemporaryDir } = await temporaryDirManager.init();
|
||||
|
||||
const inMemoryBuiltHandlerPath =
|
||||
await this.logicFunctionResourceService.copyBuiltCodeInMemory({
|
||||
workspaceId: flatLogicFunction.workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
builtHandlerPath: flatLogicFunction.builtHandlerPath,
|
||||
inMemoryDestinationPath: sourceTemporaryDir,
|
||||
});
|
||||
|
||||
await this.assembleNodeModules({
|
||||
sourceTemporaryDir,
|
||||
flatApplication,
|
||||
applicationUniversalIdentifier,
|
||||
});
|
||||
|
||||
const inMemoryBuiltHandlerPath =
|
||||
executionMode === LogicFunctionExecutionMode.PREBUILT
|
||||
? await this.copyPrebuiltBundleIntoExecutionDir({
|
||||
flatLogicFunction,
|
||||
sourceTemporaryDir,
|
||||
})
|
||||
: await this.logicFunctionResourceService.copyBuiltCodeInMemory({
|
||||
workspaceId: flatLogicFunction.workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
builtHandlerPath: flatLogicFunction.builtHandlerPath,
|
||||
inMemoryDestinationPath: sourceTemporaryDir,
|
||||
});
|
||||
|
||||
let logs = '';
|
||||
|
||||
const consoleListener = new ConsoleListener();
|
||||
@@ -561,4 +595,114 @@ export class LocalDriver implements LogicFunctionDriver {
|
||||
child.on('close', () => clearTimeout(t));
|
||||
});
|
||||
}
|
||||
|
||||
private getPrebuiltBundleDir(flatLogicFunction: FlatLogicFunction): string {
|
||||
return join(
|
||||
LOGIC_FUNCTION_EXECUTOR_TMPDIR_FOLDER,
|
||||
'prebuilt',
|
||||
flatLogicFunction.id,
|
||||
);
|
||||
}
|
||||
|
||||
private getInstalledBundlePath(flatLogicFunction: FlatLogicFunction): string {
|
||||
return join(
|
||||
this.getPrebuiltBundleDir(flatLogicFunction),
|
||||
PREBUILT_BUNDLE_FILE_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
private async copyPrebuiltBundleIntoExecutionDir({
|
||||
flatLogicFunction,
|
||||
sourceTemporaryDir,
|
||||
}: {
|
||||
flatLogicFunction: FlatLogicFunction;
|
||||
sourceTemporaryDir: string;
|
||||
}): Promise<string> {
|
||||
const installedBundlePath = this.getInstalledBundlePath(flatLogicFunction);
|
||||
const localBundlePath = join(sourceTemporaryDir, PREBUILT_BUNDLE_FILE_NAME);
|
||||
|
||||
await fs.copyFile(installedBundlePath, localBundlePath);
|
||||
|
||||
return localBundlePath;
|
||||
}
|
||||
|
||||
private getInstalledChecksumPath(
|
||||
flatLogicFunction: FlatLogicFunction,
|
||||
): string {
|
||||
return join(
|
||||
this.getPrebuiltBundleDir(flatLogicFunction),
|
||||
PREBUILT_CHECKSUM_FILE_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
private getPrebuiltInstallLockKey(flatLogicFunction: FlatLogicFunction) {
|
||||
return `local-install:${flatLogicFunction.id}`;
|
||||
}
|
||||
|
||||
async installPrebuiltBundle({
|
||||
flatLogicFunction,
|
||||
applicationUniversalIdentifier,
|
||||
}: LogicFunctionInstallPrebuiltBundleParams): Promise<void> {
|
||||
if (!isNonEmptyString(flatLogicFunction.checksum)) {
|
||||
throw new LogicFunctionException(
|
||||
`Cannot install prebuilt bundle for function '${flatLogicFunction.id}' without a checksum`,
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
|
||||
);
|
||||
}
|
||||
|
||||
const checksum = flatLogicFunction.checksum;
|
||||
|
||||
await this.cacheLockService.withLock(
|
||||
async () => {
|
||||
const prebuiltDir = this.getPrebuiltBundleDir(flatLogicFunction);
|
||||
|
||||
await fs.mkdir(prebuiltDir, { recursive: true });
|
||||
|
||||
await this.logicFunctionResourceService.copyBuiltCodeInMemory({
|
||||
workspaceId: flatLogicFunction.workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
builtHandlerPath: flatLogicFunction.builtHandlerPath,
|
||||
inMemoryDestinationPath: prebuiltDir,
|
||||
});
|
||||
|
||||
const downloadedPath = join(
|
||||
prebuiltDir,
|
||||
flatLogicFunction.builtHandlerPath,
|
||||
);
|
||||
const targetPath = this.getInstalledBundlePath(flatLogicFunction);
|
||||
|
||||
if (downloadedPath !== targetPath) {
|
||||
await fs.mkdir(dirname(targetPath), { recursive: true });
|
||||
await fs.rename(downloadedPath, targetPath);
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
this.getInstalledChecksumPath(flatLogicFunction),
|
||||
checksum,
|
||||
'utf8',
|
||||
);
|
||||
},
|
||||
this.getPrebuiltInstallLockKey(flatLogicFunction),
|
||||
{
|
||||
ttl: PREBUILT_INSTALL_LOCK_TTL_MS,
|
||||
ms: PREBUILT_INSTALL_LOCK_RETRY_MS,
|
||||
maxRetries: PREBUILT_INSTALL_LOCK_MAX_RETRIES,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async getInstalledBundleChecksum(
|
||||
flatLogicFunction: FlatLogicFunction,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const checksum = await fs.readFile(
|
||||
this.getInstalledChecksumPath(flatLogicFunction),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return checksum.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -1,5 +1,6 @@
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { type LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
|
||||
import { type LogicFunctionExecutionMode } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
|
||||
export type LogicFunctionExecuteError = {
|
||||
@@ -23,6 +24,13 @@ export type LogicFunctionExecuteParams = {
|
||||
payload: object;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
forceExecutionMode?: LogicFunctionExecutionMode;
|
||||
};
|
||||
|
||||
export type LogicFunctionInstallPrebuiltBundleParams = {
|
||||
flatLogicFunction: FlatLogicFunction;
|
||||
flatApplication: FlatApplication;
|
||||
applicationUniversalIdentifier: string;
|
||||
};
|
||||
|
||||
export type LogicFunctionTranspileParams = {
|
||||
@@ -43,6 +51,12 @@ export interface LogicFunctionDriver {
|
||||
transpile(
|
||||
params: LogicFunctionTranspileParams,
|
||||
): Promise<LogicFunctionTranspileResult>;
|
||||
installPrebuiltBundle(
|
||||
params: LogicFunctionInstallPrebuiltBundleParams,
|
||||
): Promise<void>;
|
||||
getInstalledBundleChecksum(
|
||||
flatLogicFunction: FlatLogicFunction,
|
||||
): Promise<string | null>;
|
||||
}
|
||||
|
||||
export enum LogicFunctionDriverType {
|
||||
|
||||
+2
@@ -5,6 +5,7 @@ import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/a
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
@@ -20,6 +21,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
SubscriptionsModule,
|
||||
WorkspaceCacheModule,
|
||||
BillingModule,
|
||||
FeatureFlagModule,
|
||||
TypeOrmModule.forFeature([ApplicationRegistrationVariableEntity]),
|
||||
],
|
||||
providers: [LogicFunctionExecutorService],
|
||||
|
||||
+72
-1
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_API_URL_NAME,
|
||||
DEFAULT_APP_ACCESS_TOKEN_NAME,
|
||||
} from 'twenty-shared/application';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
@@ -26,6 +27,7 @@ import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/uti
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
@@ -37,6 +39,11 @@ import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-res
|
||||
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { LogicFunctionExecutionMode } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/logic-function/logic-function.exception';
|
||||
import { FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
|
||||
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
@@ -76,6 +83,7 @@ export class LogicFunctionExecutorService {
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
@InjectRepository(ApplicationRegistrationVariableEntity)
|
||||
private readonly applicationRegistrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
|
||||
) {}
|
||||
@@ -86,12 +94,14 @@ export class LogicFunctionExecutorService {
|
||||
payload,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
executionMode,
|
||||
}: {
|
||||
logicFunctionId: string;
|
||||
workspaceId: string;
|
||||
payload: object;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
executionMode?: LogicFunctionExecutionMode;
|
||||
}): Promise<LogicFunctionExecuteResult> {
|
||||
await this.throttleExecution(workspaceId);
|
||||
|
||||
@@ -111,6 +121,19 @@ export class LogicFunctionExecutorService {
|
||||
|
||||
const driver = this.logicFunctionDriverFactory.getCurrentDriver();
|
||||
|
||||
const effectiveExecutionMode = await this.resolveEffectiveExecutionMode({
|
||||
workspaceId,
|
||||
flatLogicFunction,
|
||||
callerOverride: executionMode,
|
||||
});
|
||||
|
||||
if (effectiveExecutionMode === LogicFunctionExecutionMode.PREBUILT) {
|
||||
await this.assertPrebuiltBundleInstalled({
|
||||
driver,
|
||||
flatLogicFunction,
|
||||
});
|
||||
}
|
||||
|
||||
let resultLogicFunction: LogicFunctionExecuteResult;
|
||||
|
||||
try {
|
||||
@@ -121,13 +144,15 @@ export class LogicFunctionExecutorService {
|
||||
payload,
|
||||
env: envVariables,
|
||||
timeoutMs: flatLogicFunction.timeoutSeconds * 1_000,
|
||||
forceExecutionMode: effectiveExecutionMode,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Logic function execution failed: ` +
|
||||
`functionId=${logicFunctionId}, ` +
|
||||
`workspaceId=${workspaceId}, ` +
|
||||
`driver=${driver.constructor.name}: ` +
|
||||
`driver=${driver.constructor.name}, ` +
|
||||
`mode=${effectiveExecutionMode}: ` +
|
||||
`${error instanceof Error ? error.message : String(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
@@ -144,6 +169,52 @@ export class LogicFunctionExecutorService {
|
||||
return resultLogicFunction;
|
||||
}
|
||||
|
||||
private async resolveEffectiveExecutionMode({
|
||||
workspaceId,
|
||||
flatLogicFunction,
|
||||
callerOverride,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
flatLogicFunction: FlatLogicFunction;
|
||||
callerOverride?: LogicFunctionExecutionMode;
|
||||
}): Promise<LogicFunctionExecutionMode> {
|
||||
if (isDefined(callerOverride)) {
|
||||
return callerOverride;
|
||||
}
|
||||
|
||||
const isPrebuiltModeEnabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isPrebuiltModeEnabled) {
|
||||
return LogicFunctionExecutionMode.LIVE;
|
||||
}
|
||||
|
||||
return flatLogicFunction.executionMode ?? LogicFunctionExecutionMode.LIVE;
|
||||
}
|
||||
|
||||
private async assertPrebuiltBundleInstalled({
|
||||
driver,
|
||||
flatLogicFunction,
|
||||
}: {
|
||||
driver: ReturnType<LogicFunctionDriverFactory['getCurrentDriver']>;
|
||||
flatLogicFunction: FlatLogicFunction;
|
||||
}): Promise<void> {
|
||||
const installedChecksum =
|
||||
await driver.getInstalledBundleChecksum(flatLogicFunction);
|
||||
|
||||
if (installedChecksum !== flatLogicFunction.checksum) {
|
||||
throw new LogicFunctionException(
|
||||
`Prebuilt bundle is not installed for function '${flatLogicFunction.id}' ` +
|
||||
`(installed=${installedChecksum ?? 'none'}, expected=${flatLogicFunction.checksum ?? 'none'}). ` +
|
||||
`Rebuild and try again.`,
|
||||
LogicFunctionExceptionCode.LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async transpile(
|
||||
params: LogicFunctionTranspileParams,
|
||||
): Promise<LogicFunctionTranspileResult> {
|
||||
|
||||
+9
-1
@@ -1,6 +1,7 @@
|
||||
import { type DynamicModule, Global, Module } from '@nestjs/common';
|
||||
|
||||
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
|
||||
import { LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-driver-factory.token';
|
||||
import { LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory';
|
||||
import { LogicFunctionResourceModule } from 'src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.module';
|
||||
import { LogicFunctionTriggerModule } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module';
|
||||
@@ -24,9 +25,16 @@ export class LogicFunctionModule {
|
||||
SdkClientModule,
|
||||
WorkspaceCacheModule,
|
||||
],
|
||||
providers: [LogicFunctionDriverFactory],
|
||||
providers: [
|
||||
LogicFunctionDriverFactory,
|
||||
{
|
||||
provide: LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN,
|
||||
useExisting: LogicFunctionDriverFactory,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
LogicFunctionDriverFactory,
|
||||
LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN,
|
||||
LogicFunctionResourceModule,
|
||||
LogicFunctionTriggerModule,
|
||||
LogicFunctionExecutorModule,
|
||||
|
||||
Reference in New Issue
Block a user