Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions conformance-tests/src/main/java/map/MapItemNamer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package map;

import java.util.List;
import software.amazon.lambda.durable.DurableContext;
import software.amazon.lambda.durable.DurableHandler;
import software.amazon.lambda.durable.config.MapConfig;
import software.amazon.lambda.durable.model.MapResult;

/** 9-13: Map with a custom item namer. */
public class MapItemNamer extends DurableHandler<List<Integer>, List<Integer>> {

@Override
public List<Integer> handleRequest(List<Integer> input, DurableContext context) {
var config = MapConfig.builder()
.maxConcurrency(1)
.itemNamer((item, index) -> "item-" + item)
.build();
MapResult<Integer> result =
context.map("named-items", input, Integer.class, (item, index, ctx) -> item * 10, config);
return result.results();
}
}
18 changes: 16 additions & 2 deletions conformance-tests/template_map.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ Resources:
reason: "items-only form (no name): every Java context.map overload requires a name argument"
- id: "9-6"
reason: "throw-if-error rethrow: MapResult has no throw-if-error and map exposes no per-item futures"
- id: "9-13"
reason: "custom item namer: Java MapConfig has no item-namer field"
- id: "9-14"
reason: "custom per-item serdes: Java MapConfig has a single serDes (no separate item-level serdes distinct from the result serde)"
- id: "9-19"
Expand Down Expand Up @@ -95,6 +93,22 @@ Resources:
RetentionPeriodInDays: 7
ExecutionTimeout: 300

MapItemNamer:
Type: AWS::Serverless::Function
TestingMetadata:
TestDescription: ["9-13"]
Properties:
CodeUri: .
Handler: map.MapItemNamer
Description: Map with custom iteration names
Role:
Fn::GetAtt:
- DurableFunctionRole
- Arn
DurableConfig:
RetentionPeriodInDays: 7
ExecutionTimeout: 300

MapEmpty:
Type: AWS::Serverless::Function
TestingMetadata:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
Expand All @@ -19,6 +20,7 @@
import software.amazon.lambda.durable.model.ConcurrencyCompletionStatus;
import software.amazon.lambda.durable.model.ExecutionStatus;
import software.amazon.lambda.durable.model.MapResult;
import software.amazon.lambda.durable.model.OperationSubType;
import software.amazon.lambda.durable.model.WaitForConditionResult;
import software.amazon.lambda.durable.retry.WaitStrategies;
import software.amazon.lambda.durable.serde.JacksonSerDes;
Expand Down Expand Up @@ -1888,4 +1890,188 @@ void testEmptyMapReplayUsesCheckpoint(NestingType nestingType, int events) {
assertEquals(firstRunCount, executionCount.get(), "Map functions should not re-execute on replay");
assertEquals(events, result2.getHistoryEvents().size());
}

@Test
void testItemNamerUsesCustomAndNullIterationNames() {
var runner = LocalDurableTestRunner.create(String.class, (input, context) -> {
var result = context.map(
"named-map",
List.of("a", "b", "c"),
String.class,
(item, index, ctx) -> item.toUpperCase(),
MapConfig.builder()
.itemNamer((item, index) -> index == 1 ? null : item + "-" + index)
.build());
return String.join(",", result.results());
});

var first = runner.runUntilComplete("test");
assertEquals(ExecutionStatus.SUCCEEDED, first.getStatus());
assertEquals("A,B,C", first.getResult(String.class));
var iterationNames = first.getOperations().stream()
.filter(operation -> OperationSubType.MAP_ITERATION.getValue().equals(operation.getSubtype()))
.map(operation -> operation.getName())
.toList();
assertEquals(3, iterationNames.size());
assertTrue(iterationNames.contains("a-0"));
assertTrue(iterationNames.contains("c-2"));
assertEquals(
1, iterationNames.stream().filter(name -> name == null).count(), "iteration names: " + iterationNames);
assertFalse(iterationNames.contains("named-map-iteration-1"));

var replay = runner.run("test");
assertEquals(ExecutionStatus.SUCCEEDED, replay.getStatus());
}

@Test
void testInvalidItemNameDoesNotConsumeOperationId() {
var withRejectedMap = LocalDurableTestRunner.create(String.class, (input, context) -> {
try {
context.map(
"invalid-map",
List.of("a"),
String.class,
(item, index, ctx) -> item,
MapConfig.builder().itemNamer((item, index) -> "").build());
} catch (IllegalArgumentException expected) {
// Continue so the next operation exposes whether the rejected map consumed an ID.
}
return context.step("after", String.class, stepContext -> "done");
});
var control = LocalDurableTestRunner.create(
String.class, (input, context) -> context.step("after", String.class, stepContext -> "done"));

var attempted = withRejectedMap.runUntilComplete("test");
var baseline = control.runUntilComplete("test");

assertEquals(ExecutionStatus.SUCCEEDED, attempted.getStatus());
assertNull(attempted.getOperation("invalid-map"));
assertEquals(
baseline.getOperation("after").getId(),
attempted.getOperation("after").getId());
}

@Test
void testChangedItemNameFailsCachedReplay() {
var suffix = new AtomicReference<>("first");
var runner = LocalDurableTestRunner.create(String.class, (input, context) -> {
var result = context.map(
"replay-map",
List.of("a", "b"),
String.class,
(item, index, ctx) -> item.toUpperCase(),
MapConfig.builder()
.itemNamer((item, index) -> item + "-" + suffix.get())
.build());
return String.join(",", result.results());
});

assertEquals(ExecutionStatus.SUCCEEDED, runner.runUntilComplete("test").getStatus());
suffix.set("second");

var replay = runner.run("test");

assertEquals(ExecutionStatus.FAILED, replay.getStatus());
assertTrue(replay.getError().orElseThrow().errorType().contains("NonDeterministicExecutionException"));
}

@Test
void testEmptyMapDoesNotInvokeItemNamer() {
var namerCalls = new AtomicInteger();
var runner = LocalDurableTestRunner.create(String.class, (input, context) -> {
context.map(
"empty-map",
List.<String>of(),
String.class,
(item, index, ctx) -> item,
MapConfig.builder()
.itemNamer((item, index) -> {
namerCalls.incrementAndGet();
return "unused";
})
.build());
return "done";
});

var result = runner.runUntilComplete("test");

assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
assertEquals(0, namerCalls.get());
}

@Test
void testChangedItemNameFailsStartedMapReplay() {
var suffix = new AtomicReference<>("first");
var runner = LocalDurableTestRunner.create(String.class, (input, context) -> {
var result = context.map(
"started-replay-map",
List.of("a"),
String.class,
(item, index, ctx) ->
ctx.waitForCallback("approval", String.class, (callbackId, stepContext) -> {}),
MapConfig.builder()
.itemNamer((item, index) -> item + "-" + suffix.get())
.build());
return result.getResult(0);
});

assertEquals(ExecutionStatus.PENDING, runner.run("test").getStatus());
suffix.set("second");

var replay = runner.run("test");

assertEquals(ExecutionStatus.FAILED, replay.getStatus());
assertTrue(replay.getError().orElseThrow().errorType().contains("NonDeterministicExecutionException"));
}

@Test
void testRemovingItemNamerFailsCachedReplay() {
var useItemNamer = new AtomicBoolean(true);
var runner = LocalDurableTestRunner.create(String.class, (input, context) -> {
var configBuilder = MapConfig.builder();
if (useItemNamer.get()) {
configBuilder.itemNamer((item, index) -> "custom-" + item);
}
var result = context.map(
"removed-namer-map",
List.of("a", "b"),
String.class,
(item, index, ctx) -> item.toUpperCase(),
configBuilder.build());
return String.join(",", result.results());
});

assertEquals(ExecutionStatus.SUCCEEDED, runner.runUntilComplete("test").getStatus());
useItemNamer.set(false);

var replay = runner.run("test");

assertEquals(ExecutionStatus.FAILED, replay.getStatus());
assertTrue(replay.getError().orElseThrow().errorType().contains("NonDeterministicExecutionException"));
}

@Test
void testTypedItemNamerNamesIterationsEndToEnd() {
var runner = LocalDurableTestRunner.create(String.class, (input, context) -> {
var orders = List.of(new Order("a1"), new Order("b2"));
var result = context.map(
"typed-namer-map",
orders,
String.class,
(order, index, ctx) -> order.id().toUpperCase(),
MapConfig.builder()
.itemNamer(Order.class, (order, index) -> "order-" + order.id())
.build());
return String.join(",", result.results());
});

var result = runner.runUntilComplete("test");

assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
assertEquals("A1,B2", result.getResult(String.class));
assertNotNull(result.getOperation("order-a1"));
assertNotNull(result.getOperation("order-b2"));
}

public record Order(String id) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package software.amazon.lambda.durable.config;

import java.util.Objects;
import java.util.function.BiFunction;
import software.amazon.lambda.durable.serde.SerDes;

/**
Expand All @@ -15,12 +16,17 @@ public class MapConfig {
private final CompletionConfig completionConfig;
private final SerDes serDes;
private final NestingType nestingType;
private final BiFunction<Object, Integer, String> itemNamer;

private MapConfig(Builder builder) {
this.maxConcurrency = Objects.requireNonNullElse(builder.maxConcurrency, Integer.MAX_VALUE);
this.completionConfig = Objects.requireNonNullElse(builder.completionConfig, CompletionConfig.allCompleted());
this.nestingType = Objects.requireNonNullElse(builder.nestingType, NestingType.NESTED);
this.serDes = builder.serDes;
this.itemNamer = builder.itemNamer;
if (itemNamer != null && nestingType == NestingType.FLAT) {
throw new IllegalArgumentException("itemNamer is not supported with FLAT map nesting");
}
}

/** @return max concurrent items, or null for unlimited */
Expand All @@ -43,6 +49,18 @@ public NestingType nestingType() {
return nestingType;
}

/**
* Returns the function used to name map iterations.
*
* <p>The function receives the item and its zero-based index. A non-null result must satisfy the normal operation
* name constraints. A null result is preserved as an unnamed iteration.
*
* @return the item namer, or null when default iteration naming is used
*/
public BiFunction<Object, Integer, String> itemNamer() {
return itemNamer;
}

public static Builder builder() {
return new Builder();
}
Expand All @@ -52,7 +70,8 @@ public Builder toBuilder() {
.maxConcurrency(maxConcurrency)
.completionConfig(completionConfig)
.serDes(serDes)
.nestingType(nestingType);
.nestingType(nestingType)
.itemNamer(itemNamer);
}

/** Builder for creating MapConfig instances. */
Expand All @@ -61,6 +80,7 @@ public static class Builder {
private Integer maxConcurrency;
private CompletionConfig completionConfig;
private SerDes serDes;
private BiFunction<Object, Integer, String> itemNamer;

private Builder() {}

Expand Down Expand Up @@ -105,6 +125,45 @@ public Builder nestingType(NestingType nestingType) {
return this;
}

/**
* Sets a function that names each nested map iteration.
*
* <p>The function receives the item and its zero-based index. Returning null creates an unnamed iteration.
* Non-null names are validated before the map allocates an operation ID or emits a checkpoint. Item naming is
* not supported with {@link NestingType#FLAT} because flat iterations do not have context operations.
*
* @param itemNamer the item namer, or null to use default iteration naming
* @return this builder for method chaining
*/
public Builder itemNamer(BiFunction<Object, Integer, String> itemNamer) {
this.itemNamer = itemNamer;
return this;
}

/**
* Sets a function that names each nested map iteration, typed to the map's item class.
*
* <p>Equivalent to {@link #itemNamer(BiFunction)}, but the item type is declared explicitly so the namer can
* accept the item directly instead of {@link Object}:
*
* <pre>{@code
* MapConfig.builder().itemNamer(Order.class, (order, index) -> order.id()).build();
* }</pre>
*
* <p>Each item is passed through {@link Class#cast}, so supplying a class that does not match the map's items
* fails with a {@link ClassCastException} naming the offending type.
*
* @param itemType the class of the map's items
* @param itemNamer the item namer, or null to use default iteration naming
* @param <I> the map item type accepted by the namer
* @return this builder for method chaining
*/
public <I> Builder itemNamer(Class<I> itemType, BiFunction<? super I, Integer, String> itemNamer) {
Comment thread
zhongkechen marked this conversation as resolved.
Objects.requireNonNull(itemType, "itemType cannot be null");
this.itemNamer = itemNamer == null ? null : (item, index) -> itemNamer.apply(itemType.cast(item), index);
return this;
}

public MapConfig build() {
return new MapConfig(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ public <I, O> DurableFuture<MapResult<O>> mapAsync(

// Convert to List for deterministic index-based access
var itemList = List.copyOf(items);
var iterationNames = MapOperation.resolveIterationNames(name, itemList, config);
var operationId = nextOperationId();

var operation = new MapOperation<>(
Expand All @@ -276,6 +277,7 @@ public <I, O> DurableFuture<MapResult<O>> mapAsync(
function,
resultType,
config,
iterationNames,
this);
operation.execute();
return operation;
Expand Down
Loading
Loading