AWS Durable Execution ESLint Plugin
ESLint rules for AWS Lambda durable functions. They report code that breaks replay.
How replay works
A durable function works by replay. When an execution continues after a wait, a callback, a retry, or a failure, Lambda runs your handler again from the beginning. The SDK then walks through the code up to the point where the previous run stopped:
- At a step that already finished, the SDK returns the step's saved result. It does not run the step's function again.
- At the first operation that has not finished, the SDK runs it and saves its result.
So the code inside a step runs when the step executes, and the code outside the steps runs again on every replay.
The code outside a step must be deterministic. The SDK identifies each durable operation by its position: the first operation called on a context, the second, and so on. On replay it matches each call to the saved operation at the same position. Given the same saved results, your code must make the same calls in the same order. Anything that can give a different answer the second time belongs inside a step. That includes the current time, random numbers, UUIDs, and API calls whose results change.
Two rules in this plugin protect this requirement: no-non-deterministic-outside-step
and no-closure-in-durable-operations. A third rule, no-nested-durable-operations,
checks how durable operations are nested. A fourth rule,
require-durable-operation-in-child-context, reports a child context that does no
durable work. It is off by default.
Requirements
- ESLint 8.57 or later, 9, or 10, using the flat config format (
eslint.config.js). CI tests ESLint 8.57, the latest 9, and 10.@typescript-eslint/parser8 needs the same ESLint versions. - Node.js 22 or later.
- A parser for TypeScript files, such as
@typescript-eslint/parser.
The plugin does not support the legacy eslintrc format. See Legacy eslintrc configuration.
Installation
npm install --save-dev @aws/durable-execution-sdk-js-eslint-plugin @typescript-eslint/parser
@typescript-eslint/parser lets ESLint read TypeScript. A JavaScript-only project
does not need it. Remove the languageOptions.parser lines from the examples below.
Usage
Add the recommended config to your eslint.config.js. It enables the three rules
that report code that breaks replay, as errors.
// eslint.config.js
import typescriptParser from "@typescript-eslint/parser";
import durableExecution from "@aws/durable-execution-sdk-js-eslint-plugin";
export default [
{
files: ["**/*.ts"],
languageOptions: { parser: typescriptParser },
},
durableExecution.configs["flat/recommended"],
];
In a CommonJS project, use require instead:
// eslint.config.js
const typescriptParser = require("@typescript-eslint/parser");
const durableExecution = require("@aws/durable-execution-sdk-js-eslint-plugin");
module.exports = [
{
files: ["**/*.ts"],
languageOptions: { parser: typescriptParser },
},
durableExecution.configs["flat/recommended"],
];
The recommended config has no files setting, so it applies to every file ESLint
checks. To limit it to your durable functions, spread it into your own object:
{
...durableExecution.configs["flat/recommended"],
files: ["src/handlers/**/*.ts"],
}
ESLint 8.57 uses flat config when it finds an eslint.config.js or
eslint.config.mjs file. ESLint 9 and 10 always use it.
Choose the rules yourself
Register the plugin under a name you pick, then turn on the rules you want. This
example uses the name durable:
// eslint.config.js
import durableExecution from "@aws/durable-execution-sdk-js-eslint-plugin";
export default [
{
files: ["**/*.ts"],
plugins: { durable: durableExecution },
rules: {
"durable/no-nested-durable-operations": "error",
"durable/no-closure-in-durable-operations": "error",
"durable/no-non-deterministic-outside-step": "warn",
"durable/require-durable-operation-in-child-context": "warn",
},
// languageOptions: { parser: ... } for TypeScript, as above
},
];
Rule names in the recommended config
The recommended config registers the plugin as @aws/durable-functions. ESLint
shows that name in its output, and you use it in eslint-disable comments:
// eslint-disable-next-line @aws/durable-functions/no-nested-durable-operations
Legacy eslintrc configuration
The plugin cannot be loaded from an eslintrc file such as .eslintrc.json. ESLint
adds the prefix eslint-plugin- to the name of a plugin in an eslintrc file and
then looks for that package. For this package it looks for
@aws/eslint-plugin-durable-execution-sdk-js-eslint-plugin, which does not exist.
ESLint 10 removed eslintrc.
Earlier versions of this README showed extends: ["plugin:@aws/durable-execution-sdk-js-eslint-plugin/recommended"]. That setup
never worked. If you have a project that still uses eslintrc, move it to flat
config, or follow ESLint's
migration guide.
The exported configs.recommended is the legacy form of the config. It is
deprecated and remains only so that code which reads its rules keeps working.
Rules
The first three rules are in flat/recommended as errors.
require-durable-operation-in-child-context is not, because the code it reports
still replays correctly. To use it, turn it on as shown in
Choose the rules yourself.
| Rule | What it reports |
|---|---|
no-nested-durable-operations | A durable operation started on the context of the operation it runs in |
no-closure-in-durable-operations | A variable declared outside a step that the step changes |
no-non-deterministic-outside-step | Non-deterministic code outside a step, such as the time and random values |
require-durable-operation-in-child-context (not in recommended) | A child context whose function calls no durable operation on its context |
no-nested-durable-operations
Prevents using the same context object inside its own durable operation.
A step is one unit of work, and it cannot contain other durable operations. To group
operations, run them in a child context with runInChildContext, and call them on
the child context.
Checked operations: step, wait, waitForCallback, waitForCondition,
parallel, map and runInChildContext.
❌ Incorrect
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
return await context.step("step1", async () => {
// Error: Cannot use the same context object inside its own operation
await context.wait({ seconds: 1 });
const result = await context.step("step2", async () => {
return "nested step";
});
return result;
});
},
);
✅ Correct
// Use runInChildContext for nested operations
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
return await context.runInChildContext("block1", async (childCtx) => {
await childCtx.wait({ seconds: 1 });
const result = await childCtx.step("step2", async () => {
return "nested step";
});
return result;
});
},
);
Known limitation
The rule finds the context by looking for the parameter of an arrow function or a
function expression, or for a variable declared in the file. It does not
recognize a context that is the parameter of a function declaration, such as
async function handler(event, context) { ... }, so it reports nothing there.
Write the handler as an arrow function, as in the examples above, to get the
check.
no-closure-in-durable-operations
Prevents modifying closure variables inside durable operations. Reading closure variables is allowed.
The function of a step does not run again on replay, so a change it makes to a variable outside the step does not happen on replay. The code after the step then sees a different value than it saw on the first run, which makes that code non-deterministic. To pass a value out of a step, return it from the step.
Checked operations: step, runInChildContext, waitForCondition and
waitForCallback.
❌ Incorrect
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
let a = 2;
const result = await context.runInChildContext(
async (childContext: DurableContext) => {
const stepResult = await childContext.step(async () => {
// Error: this step does not run again on replay, so this change to 'a' is lost
a = a + 1;
return "child step completed";
});
return stepResult;
},
);
return result;
},
);
✅ Correct
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
let a = 2;
const result = await context.runInChildContext(
async (childContext: DurableContext) => {
const stepResult = await childContext.step(async () => {
// Reading 'a' is OK
const value = a + 1;
return value;
});
return stepResult;
},
);
return result;
},
);
// Or use local variables
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
const result = await context.runInChildContext(
async (childContext: DurableContext) => {
const stepResult = await childContext.step(async () => {
let a = 2;
a = a + 1;
return "child step completed";
});
return stepResult;
},
);
return result;
},
);
no-non-deterministic-outside-step
Requires non-deterministic code to run inside a step. Code outside a step runs again on every replay, so it has to give the same answer each time. A step's result is saved, so a replay reads the saved value instead of running the call again.
The rule reports these calls when they are written outside a step callback:
Math.random()Date.now()andnew Date()with no argumentsperformance.now()crypto.randomBytes()andcrypto.getRandomValues()- Any call whose name contains
uuid, such ascrypto.randomUUID()oruuidv4()
The rule checks where the call is written. That includes the body of a helper function that you define outside the step. It also reports a call to a helper that contains one of these calls, directly or through other functions, when the call to the helper is outside a step.
❌ Incorrect
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
// Error: this code runs again on every replay, and both values change
const orderId = crypto.randomUUID();
const startedAt = Date.now();
await context.step("charge", async () => chargeCard(orderId));
return { orderId, startedAt };
},
);
✅ Correct
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
// The step saves these values, so a replay returns the same ones
const { orderId, startedAt } = await context.step(
"create-order",
async () => ({ orderId: crypto.randomUUID(), startedAt: Date.now() }),
);
await context.step("charge", async () => chargeCard(orderId));
return { orderId, startedAt };
},
);
require-durable-operation-in-child-context
Requires a child context to perform at least one durable operation on its context.
A child context is the context that runInChildContext passes to its function.
map passes one to the function of each item, and parallel passes one to each
branch. A child context saves the result of its function when the function
finishes. A replay returns that saved result and does not call the function again.
So a function that calls no durable operation on its context, such as step,
invoke or wait, still replays correctly. It differs from a step in two ways:
- A step retries its work when it throws. The default retry strategy makes up to six attempts. A child context fails at the first error.
- A result larger than the checkpoint size limit does not fit in the child context's checkpoint. Then the SDK runs the function again on every replay to rebuild the result.
So the rule tells you to put the work in a step.
The rule reports these functions:
- the function passed to
runInChildContext - the function passed to
map - each branch passed to
parallel, as a function or as{ name, func }
❌ Incorrect
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
// Error: no durable operation runs on ctx
await context.runInChildContext("transform", async (ctx) =>
cheapTransform(event),
);
// Error: the same for each item
await context.map("transform", event.items, async (ctx, item) =>
cheapTransform(item),
);
},
);
✅ Correct
const handler = withDurableExecution(
async (event: any, context: DurableContext) => {
// A step saves the result, and it retries the work if it throws
await context.step("transform", async () => cheapTransform(event));
// A child context that contains a durable operation
await context.map("transform", event.items, async (ctx, item) =>
ctx.step("compute", async () => cheapTransform(item)),
);
},
);
How the rule follows the context
The rule follows the context into a function declared in the same file. It does
this when the code passes the context as an argument, such as charge(ctx), and
the function is declared as function charge() {} or const charge = () => {}.
The rule does not follow a function declaration that the file assigns again.
The rule reports nothing when the context reaches code it cannot read. Examples are
an imported function, a method call such as service.run(ctx), a return value, an
object property, a rest parameter, a spread argument before the context, and
arguments. That code may perform a durable operation.
Known limitations
map and parallel are common method names. Lodash has _.map, for example. So
the rule checks a map or parallel call only when it knows the receiver is a
durable context. The receiver must be a parameter that is one of these:
- annotated with the type
DurableContext, including an import alias such asimport type { DurableContext as Ctx } - the second parameter of the function passed to
withDurableExecution, inline or by name - the context parameter of a
runInChildContext,maporparallelfunction
The rule checks runInChildContext on any receiver, because the name is specific
to the SDK.
Why These Rules?
Replay runs the code outside the steps once on the first run, and again on every replay. The SDK can resume an execution correctly only if each run makes the same durable calls in the same order. The first two rules below protect that requirement. The third protects the structure of nested operations.
No Non-Deterministic Code Outside a Step
Code that reads the clock or generates a random value gives a different answer on
each run. If it runs outside a step, a replay can compute a different value than
the first run did. A later if statement can then take another branch, and the code
makes different durable calls than the ones the SDK saved. Wrapping the call in a
step saves its result, so every replay reads the same value.
No Closure in Durable Operations
A step's function does not run again on replay. If it changes a variable outside the step, the first run sees the change and a replay does not. The code after the step then works with different values on the two runs. Return the value from the step instead, because the SDK saves what a step returns.
Reading closure variables is safe because it doesn't change state. Only mutations (assignments, increments, decrements) are problematic.
No Nested Durable Operations
A durable operation that uses the same context as the operation it runs in can cause runtime errors and unexpected behavior. The SDK expects grouped work to run in a child context. This rule helps catch these issues at development time.
Require a Durable Operation in a Child Context
A child context groups durable operations. A child context with no durable operation still saves its result, so it replays correctly. That is why the rule is not in the recommended config. A step does the same work with retries, so the rule points to a step.
License
Apache-2.0