diff --git a/README.md b/README.md index 3ad777e..58ee219 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,23 @@ stepFunctions: ... ``` +### Auto-configure Task timeouts + +Lambda function timeouts are not surfaced as `States.Timeout` errors in Step Functions; they appear as `States.TaskFailed`. To bridge the gap you can set `TimeoutSeconds` on every Task state, but it's easy to forget and tedious to keep in sync with each function's `timeout`. + +Set `configureTaskTimeouts: true` on a state machine to automatically inject `TimeoutSeconds` into Task states that invoke a Lambda function defined in the same Serverless service. The injected value is the function's configured `timeout`, falling back to the service-wide `provider.timeout`, and finally to the Serverless Framework default of 6 seconds. This works for both the legacy direct-invoke form (`Resource: !GetAtt fn.Arn`) and the service-integration form (`Resource: arn:aws:states:::lambda:invoke`), and recurses into `Parallel` branches and `Map` iterators. + +```yaml +stepFunctions: + stateMachines: + hellostepfunc1: + configureTaskTimeouts: true + definition: + ... +``` + +If a Task state already declares `TimeoutSeconds` or `TimeoutSecondsPath`, the existing value is preserved. If the user-set `TimeoutSeconds` is strictly greater than the Lambda's timeout, a warning is logged at deploy time — the Lambda will fail before the state-level timeout fires, so the longer value has no effect. + ### Pre-deployment validation By default, your state machine definition will be validated during deployment by StepFunctions. This can be cumbersome when developing because you have to upload your service for every typo in your definition. In order to go faster, you can enable pre-deployment validation using [asl-validator](https://www.npmjs.com/package/asl-validator) which should detect most of the issues (like a missing state property). diff --git a/fixtures/configure-task-timeouts/handler.js b/fixtures/configure-task-timeouts/handler.js new file mode 100644 index 0000000..fd618b3 --- /dev/null +++ b/fixtures/configure-task-timeouts/handler.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports.fn = async () => ({ statusCode: 200 }); diff --git a/fixtures/configure-task-timeouts/serverless.yml b/fixtures/configure-task-timeouts/serverless.yml new file mode 100644 index 0000000..6834ee0 --- /dev/null +++ b/fixtures/configure-task-timeouts/serverless.yml @@ -0,0 +1,65 @@ +service: integration-configure-task-timeouts + +provider: ${file(../base.yml):provider} +plugins: ${file(../base.yml):plugins} +package: ${file(../base.yml):package} +custom: ${file(../base.yml):custom} + +functions: + short: + handler: handler.fn + timeout: 12 + long: + handler: handler.fn + timeout: 90 + defaulted: + handler: handler.fn + +stepFunctions: + stateMachines: + autoTimeoutsMachine: + name: integration-configure-task-timeouts-${opt:stage, 'test'} + configureTaskTimeouts: true + definition: + StartAt: DirectInvoke + States: + DirectInvoke: + Type: Task + Resource: + Fn::GetAtt: [ShortLambdaFunction, Arn] + Next: ServiceIntegration + ServiceIntegration: + Type: Task + Resource: arn:aws:states:::lambda:invoke + Parameters: + FunctionName: + Fn::GetAtt: [LongLambdaFunction, Arn] + Payload.$: $ + Next: UserSetTimeout + UserSetTimeout: + Type: Task + Resource: + Fn::GetAtt: [ShortLambdaFunction, Arn] + TimeoutSeconds: 5 + Next: ParallelStep + ParallelStep: + Type: Parallel + End: true + Branches: + - StartAt: NestedTask + States: + NestedTask: + Type: Task + Resource: + Fn::GetAtt: [DefaultedLambdaFunction, Arn] + End: true + backCompatMachine: + name: integration-back-compat-task-timeouts-${opt:stage, 'test'} + definition: + StartAt: NoInjection + States: + NoInjection: + Type: Task + Resource: + Fn::GetAtt: [ShortLambdaFunction, Arn] + End: true diff --git a/fixtures/configure-task-timeouts/verify.test.js b/fixtures/configure-task-timeouts/verify.test.js new file mode 100644 index 0000000..4ceb854 --- /dev/null +++ b/fixtures/configure-task-timeouts/verify.test.js @@ -0,0 +1,68 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const expect = require('chai').expect; + +const templatePath = path.join(__dirname, '.serverless', 'cloudformation-template-update-stack.json'); + +const findStateMachine = (resources, namePrefix) => { + const entry = Object.values(resources).find( + (r) => r.Type === 'AWS::StepFunctions::StateMachine' + && typeof r.Properties.StateMachineName === 'string' + && r.Properties.StateMachineName.startsWith(namePrefix), + ); + return entry || null; +}; + +const parseDefinition = (stateMachine) => { + const ds = stateMachine.Properties.DefinitionString; + if (typeof ds === 'string') return JSON.parse(ds); + const sub = ds['Fn::Sub']; + return JSON.parse(Array.isArray(sub) ? sub[0] : sub); +}; + +describe('configure-task-timeouts fixture — CloudFormation template', () => { + let resources; + + before(() => { + const template = JSON.parse(fs.readFileSync(templatePath, 'utf8')); + resources = template.Resources; + }); + + describe('autoTimeoutsMachine (configureTaskTimeouts: true)', () => { + let definition; + + before(() => { + const sm = findStateMachine(resources, 'integration-configure-task-timeouts-'); + expect(sm, 'auto-timeouts state machine should exist').to.not.equal(null); + definition = parseDefinition(sm); + }); + + it('injects TimeoutSeconds for legacy direct invoke (Resource: Fn::GetAtt) using function.timeout', () => { + expect(definition.States.DirectInvoke.TimeoutSeconds).to.equal(12); + }); + + it('injects TimeoutSeconds for service-integration lambda:invoke using function.timeout', () => { + expect(definition.States.ServiceIntegration.TimeoutSeconds).to.equal(90); + }); + + it('preserves a user-set TimeoutSeconds and does not overwrite it', () => { + expect(definition.States.UserSetTimeout.TimeoutSeconds).to.equal(5); + }); + + it('falls back to the Serverless Framework default (6s) when the function has no timeout configured', () => { + const nested = definition.States.ParallelStep.Branches[0].States.NestedTask; + expect(nested.TimeoutSeconds).to.equal(6); + }); + }); + + describe('backCompatMachine (configureTaskTimeouts unset)', () => { + it('does not inject TimeoutSeconds when the flag is off', () => { + const sm = findStateMachine(resources, 'integration-back-compat-task-timeouts-'); + expect(sm, 'back-compat state machine should exist').to.not.equal(null); + const definition = parseDefinition(sm); + expect(definition.States.NoInjection.TimeoutSeconds).to.equal(undefined); + }); + }); +}); diff --git a/fixtures/package-lock.json b/fixtures/package-lock.json index fff7739..00e2681 100644 --- a/fixtures/package-lock.json +++ b/fixtures/package-lock.json @@ -13,12 +13,11 @@ }, "..": { "name": "serverless-step-functions", - "version": "3.29.0", + "version": "3.29.2", "dev": true, "license": "MIT", "dependencies": { "@osls/compose": "^1.4.0", - "@serverless/utils": "^6.7.0", "asl-validator": "^3.11.0", "bluebird": "^3.4.0", "chalk": "^4.1.2", diff --git a/lib/deploy/stepFunctions/compileStateMachines.js b/lib/deploy/stepFunctions/compileStateMachines.js index d02cb36..723798c 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.js +++ b/lib/deploy/stepFunctions/compileStateMachines.js @@ -5,6 +5,7 @@ const aslValidator = require('asl-validator'); const BbPromise = require('bluebird'); const crypto = require('node:crypto'); const schema = require('./compileStateMachines.schema'); +const configureTaskTimeouts = require('./configureTaskTimeouts'); const { isIntrinsic, translateLocalFunctionNames, convertToFunctionVersion, resolveLambdaFunctionName, } = require('../../utils/aws'); @@ -135,6 +136,15 @@ module.exports = { DefinitionString = JSON.stringify(stateMachineObj.definition) .replace(/\\n|\\r|\\n\\r/g, ''); } else { + if (stateMachineObj.configureTaskTimeouts === true) { + configureTaskTimeouts({ + definition: stateMachineObj.definition, + functions: this.serverless.service.functions, + providerTimeout: this.serverless.service.provider.timeout, + getLambdaLogicalId: (key) => this.provider.naming.getLambdaLogicalId(key), + stateMachineName, + }); + } const functionMappings = Array.from(getIntrinsicFunctions(stateMachineObj.definition)); const { replaced, definition } = replacePseudoParameters(stateMachineObj.definition); const definitionString = JSON.stringify(definition, undefined, 2); diff --git a/lib/deploy/stepFunctions/compileStateMachines.schema.js b/lib/deploy/stepFunctions/compileStateMachines.schema.js index 9314ead..3f947bd 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.schema.js +++ b/lib/deploy/stepFunctions/compileStateMachines.schema.js @@ -72,6 +72,7 @@ const events = Joi.array(); const alarms = Joi.object(); const notifications = Joi.object(); const useExactVersion = Joi.boolean().default(false); +const configureTaskTimeouts = Joi.boolean().default(false); const type = Joi.string().valid('STANDARD', 'EXPRESS').default('STANDARD'); const retain = Joi.boolean().default(false); @@ -81,6 +82,7 @@ const schema = Joi.object().keys({ name, role: arn, useExactVersion, + configureTaskTimeouts, definition: definition.required(), dependsOn, tags, diff --git a/lib/deploy/stepFunctions/compileStateMachines.test.js b/lib/deploy/stepFunctions/compileStateMachines.test.js index 6d45609..5b8a2c5 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.test.js +++ b/lib/deploy/stepFunctions/compileStateMachines.test.js @@ -2239,4 +2239,58 @@ describe('#compileStateMachines', () => { expect(result).to.have.property('then').that.is.a('function'); return result; }); + + describe('configureTaskTimeouts', () => { + beforeEach(() => { + serverless.service.functions = { + hello: { handler: 'h.fn', timeout: 30 }, + }; + }); + + const buildSm = (extra) => ({ + stateMachines: { + myStateMachine1: { + id: 'Test', + ...extra, + definition: { + StartAt: 'Hello', + States: { + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }, + }, + }, + }, + }); + + const compileAndGetState = () => { + serverlessStepFunctions.compileStateMachines(); + const stateMachine = serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.Test; + const subContent = stateMachine.Properties.DefinitionString['Fn::Sub']; + const definitionJson = Array.isArray(subContent) ? subContent[0] : subContent; + const parsed = JSON.parse(definitionJson); + return parsed.States.Hello; + }; + + it('does not inject TimeoutSeconds when configureTaskTimeouts is not set', () => { + serverless.service.stepFunctions = buildSm(); + const hello = compileAndGetState(); + expect(hello.TimeoutSeconds).to.equal(undefined); + }); + + it('injects TimeoutSeconds from the lambda config when configureTaskTimeouts is true', () => { + serverless.service.stepFunctions = buildSm({ configureTaskTimeouts: true }); + const hello = compileAndGetState(); + expect(hello.TimeoutSeconds).to.equal(30); + }); + + it('rejects unknown configureTaskTimeouts values via schema', () => { + serverless.service.stepFunctions = buildSm({ configureTaskTimeouts: 'yes' }); + expect(() => serverlessStepFunctions.compileStateMachines()).to.throw(/malformed/); + }); + }); }); diff --git a/lib/deploy/stepFunctions/configureTaskTimeouts.js b/lib/deploy/stepFunctions/configureTaskTimeouts.js new file mode 100644 index 0000000..5cb78de --- /dev/null +++ b/lib/deploy/stepFunctions/configureTaskTimeouts.js @@ -0,0 +1,175 @@ +'use strict'; + +const _ = require('lodash'); +const logger = require('../../utils/logger'); + +// Serverless Framework's fallback when neither function.timeout nor +// provider.timeout is set (osls/lib/plugins/aws/package/compile/functions.js). +// AWS Lambda's own default is 3s, but the Serverless-deployed function gets +// this value baked into the CloudFormation template, so it's what we mirror. +const SERVERLESS_DEFAULT_TIMEOUT_SECONDS = 6; +const LAMBDA_INVOKE_RESOURCE_PREFIX = 'arn:aws:states:::lambda:invoke'; + +function getFunctionKeyFromRef(ref, functions, logicalIdToKey) { + if (!ref || typeof ref !== 'object') return null; + + if (typeof ref.Ref === 'string') { + const value = ref.Ref; + if (Object.hasOwn(functions, value)) return value; + return logicalIdToKey[value] || null; + } + + if (Array.isArray(ref['Fn::GetAtt']) && typeof ref['Fn::GetAtt'][0] === 'string') { + const logicalId = ref['Fn::GetAtt'][0]; + if (Object.hasOwn(functions, logicalId)) return logicalId; + return logicalIdToKey[logicalId] || null; + } + + return null; +} + +function resolveTaskFunctionKey(state, functions, logicalIdToKey) { + const resource = state.Resource; + + if (typeof resource === 'string' && resource.startsWith(LAMBDA_INVOKE_RESOURCE_PREFIX)) { + const fnName = state.Parameters && state.Parameters.FunctionName; + return getFunctionKeyFromRef(fnName, functions, logicalIdToKey); + } + + return getFunctionKeyFromRef(resource, functions, logicalIdToKey); +} + +// Yields [statePath, state] for every Task state, recursing through +// Parallel branches and Map iterators. Paths are arrays of property keys +// rooted at the definition object so _.get / _.set can address the state. +function* iterateTaskStates(states, basePath) { + if (!states || typeof states !== 'object') return; + for (const stateName of Object.keys(states)) { + const state = states[stateName]; + if (state && typeof state === 'object') { + const statePath = [...basePath, stateName]; + + if (state.Type === 'Task') { + yield [statePath, state]; + } else if (state.Type === 'Parallel' && Array.isArray(state.Branches)) { + for (let i = 0; i < state.Branches.length; i += 1) { + const branch = state.Branches[i]; + yield* iterateTaskStates( + branch && branch.States, + [...statePath, 'Branches', i, 'States'], + ); + } + } else if (state.Type === 'Map') { + let wrapper = null; + if (state.ItemProcessor) wrapper = 'ItemProcessor'; + else if (state.Iterator) wrapper = 'Iterator'; + if (wrapper) { + yield* iterateTaskStates( + state[wrapper].States, + [...statePath, wrapper, 'States'], + ); + } + } + } + } +} + +// Pure: returns a list of timeout decisions without mutating anything or +// emitting side effects. Each decision is plain data the caller can inspect, +// serialize, or feed to applyTaskTimeoutDecisions. +// +// Decision shapes: +// { action: 'inject', statePath, stateName, fnKey, lambdaTimeout } +// { action: 'warn-overlong', statePath, stateName, fnKey, lambdaTimeout, +// userTimeout } +function planTaskTimeouts({ + definition, + functions, + providerTimeout, + getLambdaLogicalId, +}) { + if (!definition || typeof definition !== 'object' || !definition.States) return []; + + const fns = functions || {}; + const fallbackTimeout = typeof providerTimeout === 'number' + ? providerTimeout + : SERVERLESS_DEFAULT_TIMEOUT_SECONDS; + + const logicalIdToKey = {}; + for (const key of Object.keys(fns)) { + const logicalId = getLambdaLogicalId(key); + if (typeof logicalId === 'string') logicalIdToKey[logicalId] = key; + } + + const decisions = []; + for (const [statePath, state] of iterateTaskStates(definition.States, ['States'])) { + const fnKey = resolveTaskFunctionKey(state, fns, logicalIdToKey); + if (fnKey) { + const fn = fns[fnKey]; + const lambdaTimeout = fn && typeof fn.timeout === 'number' + ? fn.timeout + : fallbackTimeout; + const stateName = statePath[statePath.length - 1]; + const userHasTimeout = state.TimeoutSeconds !== undefined + || state.TimeoutSecondsPath !== undefined; + + if (!userHasTimeout) { + decisions.push({ + action: 'inject', statePath, stateName, fnKey, lambdaTimeout, + }); + } else if (typeof state.TimeoutSeconds === 'number' + && state.TimeoutSeconds > lambdaTimeout) { + decisions.push({ + action: 'warn-overlong', + statePath, + stateName, + fnKey, + lambdaTimeout, + userTimeout: state.TimeoutSeconds, + }); + } + } + } + + return decisions; +} + +// Effect: applies plan decisions to the definition (mutating in place) and +// emits warnings through the supplied `log` callback. Splitting effect from +// decision lets tests assert plans as data without stubbing the logger and +// keeps the door open for dry-run / report use cases. +function applyTaskTimeoutDecisions({ + definition, + decisions, + stateMachineName, + log, +}) { + for (const d of decisions) { + if (d.action === 'inject') { + _.set(definition, [...d.statePath, 'TimeoutSeconds'], d.lambdaTimeout); + } else if (d.action === 'warn-overlong' && typeof log === 'function') { + log( + `⚠ State machine "${stateMachineName}" task "${d.stateName}": ` + + `TimeoutSeconds (${d.userTimeout}s) is greater than the Lambda function ` + + `"${d.fnKey}" timeout (${d.lambdaTimeout}s). The Lambda will fail before ` + + 'the state-level timeout fires.', + ); + } + } +} + +function configureTaskTimeouts(opts) { + const decisions = planTaskTimeouts(opts); + applyTaskTimeoutDecisions({ + definition: opts.definition, + decisions, + stateMachineName: opts.stateMachineName, + log: (msg) => logger.log(msg), + }); + return decisions; +} + +module.exports = configureTaskTimeouts; +module.exports.planTaskTimeouts = planTaskTimeouts; +module.exports.applyTaskTimeoutDecisions = applyTaskTimeoutDecisions; +module.exports.SERVERLESS_DEFAULT_TIMEOUT_SECONDS = SERVERLESS_DEFAULT_TIMEOUT_SECONDS; diff --git a/lib/deploy/stepFunctions/configureTaskTimeouts.test.js b/lib/deploy/stepFunctions/configureTaskTimeouts.test.js new file mode 100644 index 0000000..6694b33 --- /dev/null +++ b/lib/deploy/stepFunctions/configureTaskTimeouts.test.js @@ -0,0 +1,400 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const configureTaskTimeouts = require('./configureTaskTimeouts'); + +const { planTaskTimeouts, applyTaskTimeoutDecisions } = configureTaskTimeouts; + +const upperFirst = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s); +const getLambdaLogicalId = (key) => `${upperFirst(key)}LambdaFunction`; + +const buildDefinition = (states) => ({ StartAt: Object.keys(states)[0], States: states }); + +describe('planTaskTimeouts', () => { + it('plans an inject for service-integration lambda:invoke (Fn::GetAtt)', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke', + Parameters: { FunctionName: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] } }, + End: true, + }, + }); + const functions = { hello: { timeout: 30 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan, [{ + action: 'inject', + statePath: ['States', 'Hello'], + stateName: 'Hello', + fnKey: 'hello', + lambdaTimeout: 30, + }]); + }); + + it('plans an inject for legacy direct invoke (Resource: Fn::GetAtt)', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }); + const functions = { hello: { timeout: 45 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.equal(plan.length, 1); + assert.equal(plan[0].lambdaTimeout, 45); + }); + + it('resolves Ref by serverless function key (waitForTaskToken variant)', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke.waitForTaskToken', + Parameters: { FunctionName: { Ref: 'hello' } }, + End: true, + }, + }); + const functions = { hello: { timeout: 12 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.equal(plan[0].lambdaTimeout, 12); + }); + + it('falls back to Serverless Framework default of 6 seconds when nothing is set', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }); + const functions = { hello: { handler: 'h.fn' } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.equal(plan[0].lambdaTimeout, 6); + }); + + it('falls back to provider.timeout when function has no explicit timeout', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }); + const functions = { hello: { handler: 'h.fn' } }; + + const plan = planTaskTimeouts({ + definition, functions, providerTimeout: 60, getLambdaLogicalId, + }); + + assert.equal(plan[0].lambdaTimeout, 60); + }); + + it('prefers function.timeout over provider.timeout', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }); + const functions = { hello: { timeout: 15 } }; + + const plan = planTaskTimeouts({ + definition, functions, providerTimeout: 60, getLambdaLogicalId, + }); + + assert.equal(plan[0].lambdaTimeout, 15); + }); + + it('emits no decision when user TimeoutSeconds <= lambda timeout', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + TimeoutSeconds: 10, + End: true, + }, + }); + const functions = { hello: { timeout: 30 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan, []); + }); + + it('plans warn-overlong when user TimeoutSeconds is strictly greater than lambda timeout', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + TimeoutSeconds: 60, + End: true, + }, + }); + const functions = { hello: { timeout: 30 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan, [{ + action: 'warn-overlong', + statePath: ['States', 'Hello'], + stateName: 'Hello', + fnKey: 'hello', + lambdaTimeout: 30, + userTimeout: 60, + }]); + }); + + it('emits no decision when user TimeoutSeconds equals the lambda timeout', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + TimeoutSeconds: 30, + End: true, + }, + }); + const functions = { hello: { timeout: 30 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan, []); + }); + + it('emits no decision when TimeoutSecondsPath is set (runtime value unknown)', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + TimeoutSecondsPath: '$.maxTime', + End: true, + }, + }); + const functions = { hello: { timeout: 30 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan, []); + }); + + it('skips Task states that do not reference a project Lambda', () => { + const definition = buildDefinition({ + Sns: { + Type: 'Task', + Resource: 'arn:aws:states:::sns:publish', + Parameters: { TopicArn: 'arn:aws:sns:us-east-1:1:t', Message: 'hi' }, + End: true, + }, + Foreign: { + Type: 'Task', + Resource: 'arn:aws:lambda:us-east-1:1234567890:function:not-mine', + End: true, + }, + }); + const functions = { hello: { timeout: 30 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan, []); + }); + + it('addresses nested states under Parallel branches with full statePath', () => { + const definition = buildDefinition({ + P: { + Type: 'Parallel', + End: true, + Branches: [ + { + StartAt: 'A', + States: { + A: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }, + }, + { + StartAt: 'B', + States: { + B: { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke', + Parameters: { FunctionName: { Ref: 'world' } }, + End: true, + }, + }, + }, + ], + }, + }); + const functions = { hello: { timeout: 15 }, world: { timeout: 25 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan.map((d) => d.statePath), [ + ['States', 'P', 'Branches', 0, 'States', 'A'], + ['States', 'P', 'Branches', 1, 'States', 'B'], + ]); + assert.deepEqual(plan.map((d) => d.lambdaTimeout), [15, 25]); + }); + + it('addresses nested states under Map ItemProcessor', () => { + const definition = buildDefinition({ + M: { + Type: 'Map', + End: true, + ItemProcessor: { + StartAt: 'Inner', + States: { + Inner: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }, + }, + }, + }); + const functions = { hello: { timeout: 7 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan[0].statePath, ['States', 'M', 'ItemProcessor', 'States', 'Inner']); + }); + + it('addresses nested states under Map Iterator (legacy)', () => { + const definition = buildDefinition({ + M: { + Type: 'Map', + End: true, + Iterator: { + StartAt: 'Inner', + States: { + Inner: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }, + }, + }, + }); + const functions = { hello: { timeout: 8 } }; + + const plan = planTaskTimeouts({ definition, functions, getLambdaLogicalId }); + + assert.deepEqual(plan[0].statePath, ['States', 'M', 'Iterator', 'States', 'Inner']); + }); + + it('returns an empty plan for missing or stateless definitions', () => { + assert.deepEqual(planTaskTimeouts({ + definition: undefined, functions: {}, getLambdaLogicalId, + }), []); + assert.deepEqual(planTaskTimeouts({ + definition: {}, functions: {}, getLambdaLogicalId, + }), []); + }); + + it('does not mutate the input definition', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }); + const before = JSON.stringify(definition); + + planTaskTimeouts({ + definition, functions: { hello: { timeout: 30 } }, getLambdaLogicalId, + }); + + assert.equal(JSON.stringify(definition), before); + }); +}); + +describe('applyTaskTimeoutDecisions', () => { + it('writes TimeoutSeconds at the decision statePath for inject decisions', () => { + const definition = buildDefinition({ + Hello: { Type: 'Task', End: true }, + P: { + Type: 'Parallel', + End: true, + Branches: [{ StartAt: 'B', States: { B: { Type: 'Task', End: true } } }], + }, + }); + const decisions = [ + { + action: 'inject', statePath: ['States', 'Hello'], stateName: 'Hello', fnKey: 'h', lambdaTimeout: 30, + }, + { + action: 'inject', statePath: ['States', 'P', 'Branches', 0, 'States', 'B'], stateName: 'B', fnKey: 'b', lambdaTimeout: 12, + }, + ]; + + applyTaskTimeoutDecisions({ + definition, decisions, stateMachineName: 'sm', log: () => {}, + }); + + assert.equal(definition.States.Hello.TimeoutSeconds, 30); + assert.equal(definition.States.P.Branches[0].States.B.TimeoutSeconds, 12); + }); + + it('routes warn-overlong decisions through the log callback', () => { + const messages = []; + const definition = buildDefinition({ + Hello: { Type: 'Task', TimeoutSeconds: 60, End: true }, + }); + const decisions = [{ + action: 'warn-overlong', + statePath: ['States', 'Hello'], + stateName: 'Hello', + fnKey: 'hello', + lambdaTimeout: 30, + userTimeout: 60, + }]; + + applyTaskTimeoutDecisions({ + definition, decisions, stateMachineName: 'mysm', log: (m) => messages.push(m), + }); + + assert.equal(definition.States.Hello.TimeoutSeconds, 60); + assert.equal(messages.length, 1); + assert.match(messages[0], /mysm/); + assert.match(messages[0], /Hello/); + assert.match(messages[0], /60s/); + assert.match(messages[0], /30s/); + }); +}); + +describe('configureTaskTimeouts (orchestrator)', () => { + it('plans, applies, and returns the decision list', () => { + const definition = buildDefinition({ + Hello: { + Type: 'Task', + Resource: { 'Fn::GetAtt': ['HelloLambdaFunction', 'Arn'] }, + End: true, + }, + }); + + const plan = configureTaskTimeouts({ + definition, + functions: { hello: { timeout: 30 } }, + getLambdaLogicalId, + stateMachineName: 'sm', + }); + + assert.equal(definition.States.Hello.TimeoutSeconds, 30); + assert.equal(plan.length, 1); + assert.equal(plan[0].action, 'inject'); + }); +});