diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/constants/executor/index.mjs b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/constants/executor/index.mjs index d732ca0cd4..c6b1696796 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/constants/executor/index.mjs +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/constants/executor/index.mjs @@ -1,18 +1,37 @@ import { promises as fs } from 'fs'; -import { randomBytes } from 'crypto'; +import { createHash } from 'crypto'; export const handler = async (event) => { - const randomId = randomBytes(16).toString('hex'); + const { code, params, env, handlerName } = event; - const mainPath = `/tmp/${randomId}.mjs`; + // 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 + // re-parsed and re-evaluated the entire user bundle (~hundreds of ms + // for typical apps, dominating Lambda warm-start latency). + // + // Implication: any module-scoped state and side-effects in the user + // bundle (e.g. in-memory caches) are now intentionally shared across + // invocations of the same container. Logic functions are expected to + // treat module scope as a per-container cache, not as per-call isolation. + // + // Disk usage tradeoff: when a logic function's compiled bundle changes + // (e.g. on app deploy), a new file is written and the previous one stays + // until the container is recycled. This is bounded by the number of + // distinct bundle hashes a single container sees during its lifetime, + // which is small in practice; Lambda wipes /tmp on container recycle. + const codeHash = createHash('sha256').update(code).digest('hex'); + const mainPath = `/tmp/${codeHash}.mjs`; // oxlint-disable-next-line no-undef const oldProcessEnv = { ...process.env }; try { - const { code, params, env, handlerName } = event; - - await fs.writeFile(mainPath, code, 'utf8'); + try { + await fs.access(mainPath); + } catch { + await fs.writeFile(mainPath, code, 'utf8'); + } // oxlint-disable-next-line no-undef process.env = { ...process.env, ...(env ?? {}) }; @@ -25,7 +44,9 @@ export const handler = async (event) => { return await handlerFn(params); } finally { - await fs.rm(mainPath, { force: true }); + // 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; } 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 28afa5ea56..6a15eaf237 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 @@ -1104,19 +1104,49 @@ export class LambdaDriver implements LogicFunctionDriver { } } - private extractLogs(logString: string): string { - const formattedLogString = Buffer.from(logString, 'base64') - .toString('utf8') - .split('\t') - .join(' '); + private parseLambdaLogResult(logResult: string | undefined): { + logs: string; + initDurationMs: string | null; + billedDurationMs: string | null; + reportDurationMs: string | null; + coldStart: boolean; + } { + if (!logResult) { + return { + logs: '', + initDurationMs: null, + billedDurationMs: null, + reportDurationMs: null, + coldStart: false, + }; + } - return formattedLogString + const decoded = Buffer.from(logResult, 'base64').toString('utf8'); + + const initDurationMs = + decoded.match(/Init Duration:\s*([\d.]+)\s*ms/i)?.[1] ?? null; + const billedDurationMs = + decoded.match(/Billed Duration:\s*([\d.]+)\s*ms/i)?.[1] ?? null; + const reportDurationMs = + decoded.match(/\bDuration:\s*([\d.]+)\s*ms/i)?.[1] ?? null; + + const logs = decoded + .split('\t') + .join(' ') .replace(/^(START|END|REPORT).*\n?/gm, '') .replace( /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z) [a-f0-9-]+ INFO /gm, '$1 INFO ', ) .trim(); + + return { + logs, + initDurationMs, + billedDurationMs, + reportDurationMs, + coldStart: initDurationMs !== null, + }; } async execute({ @@ -1127,11 +1157,14 @@ export class LambdaDriver implements LogicFunctionDriver { env, timeoutMs = 900_000, }: LogicFunctionExecuteParams): Promise { + const buildStart = Date.now(); + await this.buildLambdaExecutor({ flatLogicFunction, flatApplication, applicationUniversalIdentifier, }); + const buildExecutorMs = Date.now() - buildStart; const startTime = Date.now(); @@ -1140,6 +1173,7 @@ export class LambdaDriver implements LogicFunctionDriver { applicationUniversalIdentifier, builtHandlerPath: flatLogicFunction.builtHandlerPath, }); + const getBuiltCodeMs = Date.now() - startTime; const executorPayload: LambdaDriverExecutorPayload = { params: payload, @@ -1148,9 +1182,10 @@ export class LambdaDriver implements LogicFunctionDriver { handlerName: flatLogicFunction.handlerName, }; + const payloadString = JSON.stringify(executorPayload); const params: InvokeCommandInput = { FunctionName: flatLogicFunction.id, - Payload: JSON.stringify(executorPayload), + Payload: payloadString, LogType: LogType.Tail, }; @@ -1159,19 +1194,31 @@ export class LambdaDriver implements LogicFunctionDriver { try { const lambdaClient = await this.getLambdaClient(); + const invokeStart = Date.now(); const result = await callWithTimeout({ callback: () => lambdaClient.send(command), timeoutMs, }); + const invokeSendMs = Date.now() - invokeStart; const parsedResult = result.Payload ? JSON.parse(result.Payload.transformToString()) : {}; - const logs = result.LogResult ? this.extractLogs(result.LogResult) : ''; + const { + logs, + initDurationMs, + billedDurationMs, + reportDurationMs, + coldStart, + } = this.parseLambdaLogResult(result.LogResult); const duration = Date.now() - startTime; + 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}`, + ); + if (result.FunctionError) { return { data: null,