Skip to content

Release sdk-2.0.0-alpha.1

Pre-release
Pre-release

Choose a tag to compare

@ParidelPooya ParidelPooya released this 14 May 18:20
Immutable release. Only release title and notes can be modified.
58cdf3c

Changelog

[2.0.0-alpha.1] — alpha — pre-release

Upgrade target for users on the 1.x line (latest: 1.1.2).

Highlights

2.0.0 ships five categories of change:

  1. withRetry — a new helper for retrying a chunk of durable logic
    (multi-operation blocks containing waitForCallback, invoke, etc.)
    with a configurable backoff strategy.
  2. Linear retry strategycreateLinearRetryStrategy and the new
    retryPresets.linear preset for fixed-increment backoff alongside the
    existing exponential strategies.
  3. Serdes upgrades — three additions:
    • context.configureSerdes() sets default serdes once instead of
      passing serdes: to every operation;
    • createFileSystemSerdes offloads large payloads to a durable mount
      (Amazon S3 Files, EFS) so executions stay under the checkpoint size
      limit;
    • inline previews keep PII out of GetDurableExecutionHistory and the
      AWS console while the full value lives on disk.
  4. Cost / scale knob for batch operationsNestingType.FLAT on
    map and parallel skips the per-iteration CONTEXT operation for
    up to 2x cost reduction and up to 2x more iterations per execution,
    trading per-branch observability for throughput.
  5. More precise error types — promise combinators and callbacks throw
    specific error subclasses (PromiseCombinatorError,
    CallbackTimeoutError, CallbackSubmitterError). This is the main
    source of breaking changes for code that branches on instanceof.

A handful of operational fixes are also included.

⚠ Upgrade guide (breaking changes)

1. Promise combinator failures now throw PromiseCombinatorError

context.Promise.all, Promise.allSettled, Promise.any, and
Promise.race previously rejected with StepError. They now reject with
PromiseCombinatorError, which extends DurableOperationError directly —
not StepError or ChildContextError.

// v1.x — no longer matches in v2
try {
  await context.Promise.all([...]);
} catch (err) {
  if (err instanceof StepError) { /* ... */ }
}

// v2.0
import { PromiseCombinatorError } from "@aws/durable-execution-sdk-js";

try {
  await context.Promise.all([...]);
} catch (err) {
  if (err instanceof PromiseCombinatorError) {
    // err.cause is the original failure from the first rejecting branch
  }
}

If you don't care about distinguishing the operation type, catch the base
class:

import { DurableOperationError } from "@aws/durable-execution-sdk-js";

try { /* ... */ }
catch (err) {
  if (err instanceof DurableOperationError) { /* ... */ }
}

2. Callback failures split into specific error types

createCallback and waitForCallback previously threw CallbackError for
every failure mode. v2 introduces two new sibling classes that do not
extend CallbackError
:

Scenario v1.x error v2 error
Callback completed with FAILED CallbackError CallbackError
Callback timed out (TIMED_OUT) CallbackError CallbackTimeoutError
waitForCallback submitter threw CallbackError CallbackSubmitterError

instanceof CallbackError will silently stop matching for the timeout
and submitter cases.

// v1.x
try {
  await context.waitForCallback("approval", submitter, { timeout: { hours: 1 } });
} catch (err) {
  if (err instanceof CallbackError) {
    // Catches timeout, submitter failure, AND callback-FAILED in v1.x
  }
}

// v2.0
import {
  CallbackError,
  CallbackTimeoutError,
  CallbackSubmitterError,
} from "@aws/durable-execution-sdk-js";

try {
  await context.waitForCallback("approval", submitter, { timeout: { hours: 1 } });
} catch (err) {
  if (err instanceof CallbackTimeoutError) {
    // Approver missed the 1h deadline
  } else if (err instanceof CallbackSubmitterError) {
    // The submitter function (e.g. publishing the approval URL) threw
  } else if (err instanceof CallbackError) {
    // Callback was completed with status=FAILED
  }
}

Added

withRetry — retry a block of durable logic

A new helper for retrying chunks of logic that contain operations a
step cannot host (e.g. waitForCallback, invoke). Semantically a
runInChildContext with a retry policy wrapped around it.

import {
  withRetry,
  createRetryStrategy,
} from "@aws/durable-execution-sdk-js";

// Named (default: wraps in runInChildContext)
const decision = await withRetry(
  context,
  "approval",
  (ctx, attempt) =>
    ctx.waitForCallback(`approval-${attempt}`, submitter, {
      timeout: { hours: 24 },
    }),
  {
    retryStrategy: createRetryStrategy({
      maxAttempts: 3,
      initialDelay: { seconds: 2 },
      backoffRate: 2,
    }),
  },
);

// Anonymous
const result = await withRetry(
  context,
  (ctx, attempt) => ctx.invoke(`charge-${attempt}`, fnArn, payload),
  { retryStrategy: (_err, n) => ({ shouldRetry: n < 3, delay: { seconds: 1 } }) },
);

createLinearRetryStrategy + retryPresets.linear

Linear backoff with configurable initial delay and increment.

import {
  createLinearRetryStrategy,
  retryPresets,
} from "@aws/durable-execution-sdk-js";

// Custom: 8 attempts, starting at 2s, +3s each attempt → 2,5,8,11,14,17,20s
const strategy = createLinearRetryStrategy(8, 2, 3);
await context.step("flaky", fn, { retryStrategy: strategy });

// Or use the new preset (6 attempts: 1,2,3,4,5s)
await context.step("flaky", fn, { retryStrategy: retryPresets.linear });

context.configureSerdes() + SerdesConfig

Set default serdes once on the context instead of passing serdes: to
every operation. Defaults flow into step, runInChildContext, invoke,
and waitForCondition. Callbacks (createCallback, waitForCallback)
require an explicit defaultCallbackDeserializer — they keep the
passthrough deserializer otherwise so customer-provided callback payloads
aren't accidentally JSON-parsed.

import {
  createClassSerdes,
  Serdes,
} from "@aws/durable-execution-sdk-js";

class Order {
  id: string = "";
  total: number = 0;
}

// Use a class-aware serdes for every step / runInChildContext /
// invoke / waitForCondition in this execution.
context.configureSerdes({
  defaultSerdes: createClassSerdes(Order),
});

// Custom serdes work the same way.
const customSerdes: Serdes<unknown> = {
  serialize:   async (v) => (v === undefined ? undefined : myEncode(v)),
  deserialize: async (s) => (s === undefined ? undefined : myDecode(s)),
};

context.configureSerdes({
  defaultSerdes: customSerdes,
  // Callbacks default to passthrough; opt-in if your callback payloads
  // need the same encoding.
  defaultCallbackDeserializer: customSerdes,
});

Per-operation serdes: arguments still win over the default — configureSerdes
only changes the fallback.

createFileSystemSerdes(basePath, config?)

A built-in Serdes that writes each value to a file under basePath
and stores only a small file pointer in the checkpoint. Use it to keep
executions under the per-checkpoint size limit (~256KB) when individual
operations produce large results.

⚠ Use a durable, shared mount. S3 Files (Lambda S3 mount) and EFS
are supported. Do not point this at Lambda's /tmp/tmp is
per-execution-environment and a replay running on a different sandbox
won't find the file, breaking deserialization.

import {
  createFileSystemSerdes,
  FileSystemSerdesMode,
} from "@aws/durable-execution-sdk-js";

// ALWAYS write to file (default) — predictable checkpoint sizes, every
// step result lives on disk.
const always = createFileSystemSerdes("/mnt/s3");

// OVERFLOW: store inline as JSON when it fits; spill to a file only when
// the inline payload would exceed the ~256KB threshold. Best for mixed
// workloads where most results are small.
const overflow = createFileSystemSerdes("/mnt/s3", {
  storageMode: FileSystemSerdesMode.OVERFLOW,
});

// Wire it up as the default for the whole execution.
context.configureSerdes({ defaultSerdes: always });

// Or use it for a single operation.
const blob = await context.step("fetch-large-blob", fetchBlob, {
  serdes: always,
});

The checkpoint envelope is one of:

{ "data": "<inline JSON>" }                          // OVERFLOW, under threshold
{ "file": "/mnt/s3/<execArn>/<entityId>.json" }      // ALWAYS, or OVERFLOW spilled

Files are organized as <basePath>/<urlEncodedExecutionArn>/<entityId>.json,
so each execution's data is namespaced and easy to clean up.

Hiding sensitive data with inline previews

When a step's result contains PII — customer name, address, phone, payment
details — storing it directly in the checkpoint exposes it through the
GetDurableExecutionHistory API and the durable-execution console UI to
anyone with read access. v2 makes it possible to keep the full value
on a durable filesystem (where replay can still read it) while storing
only a redacted preview in the checkpoint that operators see.

createFileSystemSerdes accepts an optional generatePreview function.
The returned object is stored inline in the envelope alongside the file
pointer:

{
  "file": "/mnt/s3/<execArn>/1.json",
  "preview": { "orderId": "ord-123", "status": "shipped", "total": 99.5 }
}

GetDurableExecutionHistory and the AWS console render the preview
field; the file is read only by the SDK during replay.

Example: an order workflow with PII
import {
  createFileSystemSerdes,
  buildPreview,
  PreviewMode,
} from "@aws/durable-execution-sdk-js";

interface OrderResult {
  orderId: string;
  status: "pending" | "shipped";
  total: number;
  customer: {
    fullName: string;       // PII
    email: string;          // PII
    phone: string;          // PII
    address: { street: string; city: string; zip: string }; // PII
  };
}

// Allow-list approach: start from EXCLUDE_ALL and only let the safe
// fields through. New PII fields added later are excluded automatically.
const piiSafeSerdes = createFileSystemSerdes("/mnt/s3", {
  generatePreview: (value) =>
    buildPreview(value, {
      mode: PreviewMode.EXCLUDE_ALL,
      include: [
        { name: "orderId" },
        { name: "status" },
        { name: "total" },
      ],
    }),
});

context.configureSerdes({ defaultSerdes: piiSafeSerdes });

// Now any step returning an OrderResult writes the full record to
// /mnt/s3/<execArn>/<entityId>.json. The Lambda checkpoint only stores
// { file: "...", preview: { orderId, status, total } }.
// Replay reads the full file; operators see only the preview.
Two complementary patterns

Allow-list (recommended for PII) — start with EXCLUDE_ALL, list the
fields safe to display:

buildPreview(value, {
  mode: PreviewMode.EXCLUDE_ALL,
  include: [{ name: "orderId" }, { name: "status" }],
});

Deny-list with masking — start with INCLUDE_ALL, then exclude
fields that must never appear and mask fields whose presence is useful
for debugging but whose value is sensitive (e.g. show that email exists
without leaking it):

import { FieldMatchMode } from "@aws/durable-execution-sdk-js";

buildPreview(value, {
  mode: PreviewMode.INCLUDE_ALL,
  exclude: [
    { name: "address" },                    // drop entire subtree
    { name: "ssn" },
  ],
  mask: [
    { name: "email" },                      // -> "***"
    { name: "phone" },
    { name: "fullName" },
  ],
  maskString: "[REDACTED]",                 // optional, default "***"
});
How mask actually behaves

Three rules govern interaction between include, exclude, and mask:

  1. exclude always wins. A field listed in both exclude and mask
    is excluded — its key never appears in the preview at all. Use this
    when even the presence of the field is sensitive.

  2. mask implies visibility. When a path matches a mask rule, the
    field is emitted with maskString as its value regardless of the
    mode
    . In EXCLUDE_ALL mode this means a mask entry alone makes
    the field appear (redacted) — you don't also need to add it to
    include. Use this to signal "this field exists in the result" without
    leaking its value.

  3. Masking replaces the entire subtree. When the masked path is an
    object or array, the whole value is replaced with maskString;
    buildPreview does not recurse into it.

    // value = { customer: { name: "Alice", address: { city: "Seattle" } } }
    buildPreview(value, {
      mode: PreviewMode.EXCLUDE_ALL,
      mask: [{ name: "customer" }],
    });
    // -> { customer: "***" }
    //   (NOT { customer: { name: "***", address: "***" } })

    To redact only the leaves, list each leaf in mask instead of the
    parent.

Targeting a specific path

Field selectors default to FieldMatchMode.ANYWHERE, which matches the
field name at any depth. Use FieldMatchMode.PATH to pin a selector to
an exact dot-path — useful when the same field name appears in multiple
sub-trees but only some are sensitive:

mask: [
  { name: "customer.email", match: FieldMatchMode.PATH },
  { name: "billing.email",  match: FieldMatchMode.PATH },
  // Note: this will NOT match a generic top-level `email` field.
],
Custom previews

If buildPreview doesn't fit, pass any function that returns the object
to embed:

createFileSystemSerdes("/mnt/s3", {
  generatePreview: (value: any) => ({
    orderId: value.orderId,
    customerInitials: value.customer.fullName
      .split(" ").map((n: string) => n[0]).join(""),
    summary: `Order ${value.orderId} (${value.status})`,
  }),
});

NestingType for map, parallel

Trade observability for cost on batch operations. Default is
NestingType.NESTED (today's behavior). Switching to NestingType.FLAT
skips per-iteration CONTEXT operations for up to 2x cost reduction and
higher per-execution scale (up to 2x more iterations in map), at the price of less detailed history.

import { NestingType } from "@aws/durable-execution-sdk-js";

const results = await context.map("process", items, async (ctx, item) => {
  return ctx.step("transform", () => transform(item));
}, {
  maxConcurrency: 100,
  nesting: NestingType.FLAT,    // ← new
});

Because the default is NESTED, existing code is unaffected unless
you opt in.

errorMapper on ChildConfig

runInChildContext, promise combinators, and withRetry accept a
function to remap the thrown error type. Useful when you want a domain
error class instead of ChildContextError.

class OrderError extends DurableOperationError {
  readonly errorType = "OrderError";
}

await context.runInChildContext("place-order", placeOrder, {
  errorMapper: (orig) =>
    new OrderError(`Order failed: ${orig.message}`, orig.cause),
});

Changed

  • Promise combinators are now implemented on top of runInChildContext
    (was step). This enables Lambda termination during long idle waits in
    combinators and is a prerequisite for the typed PromiseCombinatorError
    above. (#435, #436, #441)
  • FileSystemSerdesConfig.modestorageMode. Only relevant if you
    tracked the 2.0.0-alpha.1 line; v1.x users are unaffected. (#536)
  • UserAgent header uses aws-durable-execution-sdk-js/<version> and
    appends -bundled when running from the Lambda bundled runtime path.
    (#522, internal commit 5f951b1)

Fixed

  • KMS exceptions during CheckpointDurableExecution and
    GetDurableExecutionState are now treated as non-retryable customer
    errors.
    See upgrade guide §3. (#508)
  • fix(eslint-plugin): remove context.getSourceCode() so the plugin
    works on ESLint v10. (#472)
  • fix(sdk): resolve ESLint warnings surfaced during the build. (#483)
  • fix(examples): prevent duplicate DeleteFunction calls during
    cleanup in integration tests. (#486)

Security

  • Bump @aws-sdk/* deps to pull in the patched fast-xml-parser. (#488)
  • Resolve high-severity advisories in fast-xml-parser, handlebars,
    flatted, lodash, minimatch, picomatch, and vite. Updates
    eslint-plugin-tsdoc to ^0.5.2. Zero outstanding advisories at
    release time. (#501)