From 8172ccc5f12948345b9d6fcd83904b971a53f45f Mon Sep 17 00:00:00 2001 From: aschenzle Date: Fri, 25 Sep 2026 13:26:04 -0700 Subject: [PATCH 1/3] recording failed conditional writes and adding tests to verify DynamoDB distance is calculated correctly. --- .../internal/db/dynamodb/DynamoDbHandler.java | 11 +++--- .../db/dynamodb/DynamoDbHandlerTest.java | 23 +++++++++++ .../DynamoDbClassReplacement.java | 35 ++++++++++------- .../DynamoDbClassReplacementTest.java | 26 +++++++++++++ .../operations/DynamoDbOperationsData.java | 3 ++ .../operations/DynamoDbOperationsRest.java | 21 ++++++++++ .../dynamodb/DynamoDbOperationsEMTest.java | 38 +++++++++++++++++++ 7 files changed, 139 insertions(+), 18 deletions(-) diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbHandler.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbHandler.java index 8d7fd19333..31e81242cf 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbHandler.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbHandler.java @@ -159,16 +159,14 @@ public DynamoDbExecutionsDto getExecutionDto() { } /** - * Evaluates one successfully executed command and reuses table scans within the current batch. + * Evaluates one command and reuses table scans within the current batch. + * Failed conditional writes are evaluated because their predicates provide the + * distance needed to guide subsequent candidates toward a successful write. * * @param command command to evaluate * @param itemsByTable cached table contents */ private void evaluateCommand(DynamoDbCommand command, Map>> itemsByTable) { - if (!command.isSuccessfullyExecuted()) { - return; - } - Map parsedByTable; try { parsedByTable = requestParser.parseByTable(command.getDdbRequest(), command.getOperationName()); @@ -182,6 +180,9 @@ private void evaluateCommand(DynamoDbCommand command, Map values = new HashMap<>(); + values.put(":player", AttributeValue.builder().s("Lionel Scaloni").build()); + PutItemRequest request = PutItemRequest.builder() + .tableName(TABLE) + .item(item("Argentina", "Lionel Messi")) + .conditionExpression("playerName = :player") + .expressionAttributeValues(values) + .build(); + handler.handle(new DynamoDbCommand(Collections.singletonList(TABLE), DynamoDbOperationNames.PUT_ITEM, + request, false, 1L)); + + List evaluated = handler.getEvaluatedDynamoDbCommands(); + + assertEquals(1, evaluated.size()); + assertTrue(evaluated.get(0).getDistanceWithMetrics().getDistance() > 0.0d); + assertTrue(evaluated.get(0).getDistanceWithMetrics().getDistance() < 1.0d); + } + @Test public void testPrivateClientImplementation() { DynamoDbHandler handler = enabledHandler(new PrivateAsyncDynamoDbClient()); diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/DynamoDbClassReplacement.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/DynamoDbClassReplacement.java index 3066d319fc..5034e739d7 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/DynamoDbClassReplacement.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/DynamoDbClassReplacement.java @@ -147,21 +147,34 @@ protected static Object handle(Object client, String id, Object ddbRequest, Dyna try { Method method = getOriginal(Sync.singleton, id, client); Object result = method.invoke(client, ddbRequest); - - long end = System.currentTimeMillis(); - List tableNames = extractTableNames(ddbRequest); - long executionTime = end - start; - DynamoDbCommand info = new DynamoDbCommand(tableNames, operationName, ddbRequest, true, executionTime); - ExecutionTracer.addDynamoDbInfo(info); - return result; + recordDynamoDbTraceInfo(ddbRequest, start, operationName, true); + return result; } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (InvocationTargetException e) { + recordDynamoDbTraceInfo(ddbRequest, start, operationName, false); throw new RuntimeException(e.getCause()); } } + /** + * Records trace information for a DynamoDB operation, including execution time, operation details, + * and whether the operation was successfully executed. + * + * @param ddbRequest the DynamoDB request object containing operation-specific information + * @param start the start time of the operation in milliseconds, used to calculate execution duration + * @param operationName the name of the DynamoDB operation being performed (e.g., "GetItem", "PutItem") + * @param successfullyExecuted a boolean indicating if the operation was executed successfully + */ + private static void recordDynamoDbTraceInfo(Object ddbRequest, long start, DynamoDbOperationNames operationName, boolean successfullyExecuted) { + long end = System.currentTimeMillis(); + List tableNames = extractTableNames(ddbRequest); + long executionTime = end - start; + DynamoDbCommand info = new DynamoDbCommand(tableNames, operationName, ddbRequest, successfullyExecuted, executionTime); + ExecutionTracer.addDynamoDbInfo(info); + } + /** * Invoke the original asynchronous client method and trace completion status. * @@ -180,12 +193,7 @@ protected static Object handleAsync(Object client, String id, Object ddbRequest, CompletableFuture future = (CompletableFuture) result; return future.handle((res, ex) -> { - long end = System.currentTimeMillis(); - List tableNames = extractTableNames(ddbRequest); - boolean successful = ex == null; - long executionTime = end - start; - DynamoDbCommand info = new DynamoDbCommand(tableNames, operationName, ddbRequest, successful, executionTime); - ExecutionTracer.addDynamoDbInfo(info); + recordDynamoDbTraceInfo(ddbRequest, start, operationName, ex == null); if (ex != null) { if (ex instanceof RuntimeException) throw (RuntimeException) ex; throw new RuntimeException(ex); @@ -195,6 +203,7 @@ protected static Object handleAsync(Object client, String id, Object ddbRequest, } catch (IllegalAccessException e) { throw new RuntimeException(e); } catch (InvocationTargetException e) { + recordDynamoDbTraceInfo(ddbRequest, start, operationName, false); throw new RuntimeException(e.getCause()); } } diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/DynamoDbClassReplacementTest.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/DynamoDbClassReplacementTest.java index de80f3edc9..a155357f7f 100644 --- a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/DynamoDbClassReplacementTest.java +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/coverage/methodreplacement/thirdpartyclasses/DynamoDbClassReplacementTest.java @@ -29,7 +29,9 @@ import java.util.concurrent.CompletableFuture; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; public class DynamoDbClassReplacementTest { @@ -135,6 +137,30 @@ public void testPutItem() { verifyInterception(Collections.singletonList(TABLE_NAME), DynamoDbOperationNames.PUT_ITEM, request); } + @Test + public void testFailedConditionalPutItemIsIntercepted() { + Map item = Collections.singletonMap( + "id", AttributeValue.builder().s("conditional-player").build()); + syncClient.putItem(PutItemRequest.builder().tableName(TABLE_NAME).item(item).build()); + PutItemRequest request = PutItemRequest.builder() + .tableName(TABLE_NAME) + .item(item) + .conditionExpression("attribute_not_exists(id)") + .build(); + + assertThrows(RuntimeException.class, + () -> DynamoDbClassReplacement.Sync.putItem(syncClient, request)); + + List additionalInfoList = ExecutionTracer.exposeAdditionalInfoList(); + assertEquals(1, additionalInfoList.size()); + Set commands = additionalInfoList.get(0).getDynamoDbInfoData(); + assertEquals(1, commands.size()); + DynamoDbCommand command = commands.iterator().next(); + assertEquals(DynamoDbOperationNames.PUT_ITEM, command.getOperationName()); + assertEquals(request, command.getDdbRequest()); + assertFalse(command.isSuccessfullyExecuted()); + } + @Test public void testUpdateItem() { UpdateItemRequest request = UpdateItemRequest.builder() diff --git a/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/main/java/com/dynamodb/operations/DynamoDbOperationsData.java b/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/main/java/com/dynamodb/operations/DynamoDbOperationsData.java index 47327759b6..95e0ff09fc 100644 --- a/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/main/java/com/dynamodb/operations/DynamoDbOperationsData.java +++ b/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/main/java/com/dynamodb/operations/DynamoDbOperationsData.java @@ -14,8 +14,10 @@ public final class DynamoDbOperationsData { public static final String SCENARIO_ATTRIBUTE = "scenario"; public static final String SHIRT_NUMBER_ATTRIBUTE = "shirtNumber"; public static final String PLAYER_NAME_ATTRIBUTE = "playerName"; + public static final String FIFA_ID_ATTRIBUTE = "fifaId"; public static final int MISSING_SHIRT_NUMBER = 9; public static final int TARGET_SHIRT_NUMBER = 10; + public static final int TARGET_FIFA_ID = 158023; private DynamoDbOperationsData() { } @@ -78,6 +80,7 @@ public static Map key( public static Map item(ClientMode clientMode, Operation operation) { Map item = key(clientMode, operation, TARGET_SHIRT_NUMBER); item.put(PLAYER_NAME_ATTRIBUTE, AttributeValue.builder().s("Lionel Messi").build()); + item.put(FIFA_ID_ATTRIBUTE, AttributeValue.builder().n(Integer.toString(TARGET_FIFA_ID)).build()); item.put("country", AttributeValue.builder().s("Argentina").build()); return item; } diff --git a/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/main/java/com/dynamodb/operations/DynamoDbOperationsRest.java b/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/main/java/com/dynamodb/operations/DynamoDbOperationsRest.java index 28de575d29..55a1cad8d6 100644 --- a/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/main/java/com/dynamodb/operations/DynamoDbOperationsRest.java +++ b/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/main/java/com/dynamodb/operations/DynamoDbOperationsRest.java @@ -120,6 +120,27 @@ public ResponseEntity putItemAsync(@PathVariable boolean existingPlayer) return putItem(ClientMode.ASYNC, existingPlayer); } + /** + * Replaces the synchronous PutItem fixture when the supplied FIFA ID matches. + * + * @param fifaId candidate FIFA ID + * @return operation outcome + */ + @PostMapping("/sync/put-item-heuristic/{fifaId}") + public ResponseEntity putItemWithHeuristic(@PathVariable int fifaId) { + Map values = valueMap(":fifaId", fifaId); + PutItemRequest request = PutItemRequest.builder() + .tableName(DynamoDbOperationsData.TABLE_NAME) + .item(DynamoDbOperationsData.item(ClientMode.SYNC, Operation.PUT_ITEM)) + .conditionExpression("#field = :fifaId") + .expressionAttributeNames(nameMap(DynamoDbOperationsData.FIFA_ID_ATTRIBUTE)) + .expressionAttributeValues(values) + .build(); + return conditionalWrite(ClientMode.SYNC, Operation.PUT_ITEM, 201, + () -> syncClient.putItem(request), + () -> asyncClient.putItem(request)); + } + /** * Executes synchronous UpdateItem. * diff --git a/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/test/java/org/evomaster/e2etests/spring/rest/dynamodb/DynamoDbOperationsEMTest.java b/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/test/java/org/evomaster/e2etests/spring/rest/dynamodb/DynamoDbOperationsEMTest.java index 32ce847345..2b4d97f7cd 100644 --- a/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/test/java/org/evomaster/e2etests/spring/rest/dynamodb/DynamoDbOperationsEMTest.java +++ b/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/test/java/org/evomaster/e2etests/spring/rest/dynamodb/DynamoDbOperationsEMTest.java @@ -9,6 +9,8 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -54,6 +56,42 @@ public void testAllDynamoDbOperations() throws Throwable { 10); } + /** + * Verifies that DynamoDB distance guides a conditional PutItem to the required FIFA ID. + * + * @throws Throwable when EvoMaster execution fails + */ + @Test + public void testConditionalPutItemNeedsDynamoDbHeuristics() throws Throwable { + String endpoint = "/operations/sync/put-item-heuristic/{fifaId}"; + String successMarker = "SYNC PUT_ITEM SUCCESS"; + + runTestHandlingFlaky( + "DynamoDbConditionalPutItemEM", + "org.foo.spring.rest.dynamodb.DynamoDbConditionalPutItemEM", + 500, + false, + args -> { + List withoutHeuristics = new ArrayList<>(args); + configureDynamoDbHeuristics(withoutHeuristics, false); + setOption(withoutHeuristics, "endpointFocus", endpoint); + setOption(withoutHeuristics, "maxTestSize", "1"); + Solution baseline = initAndRun(withoutHeuristics); + + assertHasAtLeastOne(baseline, HttpVerb.POST, 409, endpoint, "SYNC PUT_ITEM FAILURE"); + assertNone(baseline, HttpVerb.POST, 201, endpoint, successMarker); + + List withHeuristics = new ArrayList<>(args); + configureDynamoDbHeuristics(withHeuristics, true); + setOption(withHeuristics, "endpointFocus", endpoint); + setOption(withHeuristics, "maxTestSize", "1"); + Solution guided = initAndRun(withHeuristics); + + assertHasAtLeastOne(guided, HttpVerb.POST, 201, endpoint, successMarker); + }, + 5); + } + /** * Asserts success and failure outcomes for one client-operation pair. * From 50c086d1a88434154ee29d965bbdd87561753def Mon Sep 17 00:00:00 2001 From: aschenzle Date: Thu, 8 Oct 2026 18:13:30 -0700 Subject: [PATCH 2/3] Addressing comments by adding DynamoDbTableItemsCache and splitting conditional PutItem tests. --- .../internal/db/dynamodb/DynamoDbHandler.java | 15 ++-- .../db/dynamodb/DynamoDbTableItemsCache.java | 80 +++++++++++++++++++ .../dynamodb/DynamoDbOperationsEMTest.java | 50 +++++++----- 3 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCache.java diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbHandler.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbHandler.java index 31e81242cf..dc1464dee3 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbHandler.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbHandler.java @@ -17,7 +17,6 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -141,9 +140,9 @@ public List getEvaluatedDynamoDbCommands() { return Collections.emptyList(); } - Map>> itemsByTable = new HashMap<>(); + DynamoDbTableItemsCache itemsCache = new DynamoDbTableItemsCache(tableDataAccessor, dynamoDbClient); for (DynamoDbCommand command : commands) { - evaluateCommand(command, itemsByTable); + evaluateCommand(command, itemsCache); } commands.clear(); return new ArrayList<>(evaluatedCommands); @@ -164,9 +163,9 @@ public DynamoDbExecutionsDto getExecutionDto() { * distance needed to guide subsequent candidates toward a successful write. * * @param command command to evaluate - * @param itemsByTable cached table contents + * @param itemsCache cached table contents */ - private void evaluateCommand(DynamoDbCommand command, Map>> itemsByTable) { + private void evaluateCommand(DynamoDbCommand command, DynamoDbTableItemsCache itemsCache) { Map parsedByTable; try { parsedByTable = requestParser.parseByTable(command.getDdbRequest(), command.getOperationName()); @@ -186,11 +185,7 @@ private void evaluateCommand(DynamoDbCommand command, Map> items = itemsByTable.get(tableName); - if (items == null) { - items = tableDataAccessor.getItems(dynamoDbClient, tableName); - itemsByTable.put(tableName, items); - } + List> items = itemsCache.getItems(tableName); double distance = calculator.computeDistance( parsed.getKeyCondition(), parsed.getFilterExpression(), items); DynamoDbDistanceWithMetrics metrics = new DynamoDbDistanceWithMetrics(distance, items.size(), false); diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCache.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCache.java new file mode 100644 index 0000000000..2e3e8245e1 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCache.java @@ -0,0 +1,80 @@ +package org.evomaster.client.java.controller.internal.db.dynamodb; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Caches normalized DynamoDB table items loaded during one command evaluation batch. + */ +final class DynamoDbTableItemsCache { + + private final DynamoDbTableDataAccessor tableDataAccessor; + private final Object dynamoDbClient; + private final Map>> itemsByTable = new HashMap<>(); + + /** + * Creates a cache backed by the given table accessor and DynamoDB client. + * + * @param tableDataAccessor accessor used to scan tables on cache misses + * @param dynamoDbClient SDK v2 synchronous or asynchronous DynamoDB client + */ + DynamoDbTableItemsCache(DynamoDbTableDataAccessor tableDataAccessor, Object dynamoDbClient) { + this.tableDataAccessor = Objects.requireNonNull(tableDataAccessor, + "DynamoDB table data accessor cannot be null"); + this.dynamoDbClient = dynamoDbClient; + } + + /** + * Returns the cached items for a table, scanning it on the first request in this batch. + * + * @param tableName table to read + * @return immutable normalized items for the table + */ + List> getItems(String tableName) { + String validTableName = validateTableName(tableName); + List> items = itemsByTable.get(validTableName); + if (items == null) { + items = validateItems(tableDataAccessor.getItems(dynamoDbClient, validTableName)); + itemsByTable.put(validTableName, items); + } + return items; + } + + /** + * Checks that a table name can be used as a stable cache key. + * + * @param tableName candidate table name + * @return validated table name + */ + private String validateTableName(String tableName) { + Objects.requireNonNull(tableName, "DynamoDB table name cannot be null"); + if (tableName.trim().isEmpty()) { + throw new IllegalArgumentException("DynamoDB table name cannot be blank"); + } + return tableName; + } + + /** + * Copies scanned items into an immutable structure and checks the cache invariants. + * + * @param items scanned table items + * @return immutable table items + */ + private List> validateItems(List> items) { + Objects.requireNonNull(items, "DynamoDB table items cannot be null"); + List> copy = new ArrayList<>(items.size()); + for (Map item : items) { + Objects.requireNonNull(item, "DynamoDB table item cannot be null"); + for (String attributeName : item.keySet()) { + Objects.requireNonNull(attributeName, "DynamoDB attribute name cannot be null"); + } + copy.add(Collections.unmodifiableMap(new LinkedHashMap<>(item))); + } + return Collections.unmodifiableList(copy); + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/test/java/org/evomaster/e2etests/spring/rest/dynamodb/DynamoDbOperationsEMTest.java b/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/test/java/org/evomaster/e2etests/spring/rest/dynamodb/DynamoDbOperationsEMTest.java index 2b4d97f7cd..3c992418aa 100644 --- a/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/test/java/org/evomaster/e2etests/spring/rest/dynamodb/DynamoDbOperationsEMTest.java +++ b/core-tests/e2e-tests/spring/spring-rest-dynamodb/src/test/java/org/evomaster/e2etests/spring/rest/dynamodb/DynamoDbOperationsEMTest.java @@ -9,8 +9,6 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import java.util.ArrayList; -import java.util.List; import java.util.Locale; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -57,37 +55,51 @@ public void testAllDynamoDbOperations() throws Throwable { } /** - * Verifies that DynamoDB distance guides a conditional PutItem to the required FIFA ID. + * Verifies that a conditional PutItem does not reach the required FIFA ID without DynamoDB distance. * * @throws Throwable when EvoMaster execution fails */ @Test - public void testConditionalPutItemNeedsDynamoDbHeuristics() throws Throwable { + public void testConditionalPutItemWithoutDynamoDbHeuristics() throws Throwable { String endpoint = "/operations/sync/put-item-heuristic/{fifaId}"; - String successMarker = "SYNC PUT_ITEM SUCCESS"; runTestHandlingFlaky( - "DynamoDbConditionalPutItemEM", - "org.foo.spring.rest.dynamodb.DynamoDbConditionalPutItemEM", + "DynamoDbConditionalPutItemWithoutHeuristicsEM", + "org.foo.spring.rest.dynamodb.DynamoDbConditionalPutItemWithoutHeuristicsEM", 500, false, args -> { - List withoutHeuristics = new ArrayList<>(args); - configureDynamoDbHeuristics(withoutHeuristics, false); - setOption(withoutHeuristics, "endpointFocus", endpoint); - setOption(withoutHeuristics, "maxTestSize", "1"); - Solution baseline = initAndRun(withoutHeuristics); + configureDynamoDbHeuristics(args, false); + setOption(args, "endpointFocus", endpoint); + setOption(args, "maxTestSize", "1"); + Solution baseline = initAndRun(args); assertHasAtLeastOne(baseline, HttpVerb.POST, 409, endpoint, "SYNC PUT_ITEM FAILURE"); - assertNone(baseline, HttpVerb.POST, 201, endpoint, successMarker); + assertNone(baseline, HttpVerb.POST, 201, endpoint, "SYNC PUT_ITEM SUCCESS"); + }, + 5); + } - List withHeuristics = new ArrayList<>(args); - configureDynamoDbHeuristics(withHeuristics, true); - setOption(withHeuristics, "endpointFocus", endpoint); - setOption(withHeuristics, "maxTestSize", "1"); - Solution guided = initAndRun(withHeuristics); + /** + * Verifies that DynamoDB distance guides a conditional PutItem to the required FIFA ID. + * + * @throws Throwable when EvoMaster execution fails + */ + @Test + public void testConditionalPutItemWithDynamoDbHeuristics() throws Throwable { + String endpoint = "/operations/sync/put-item-heuristic/{fifaId}"; - assertHasAtLeastOne(guided, HttpVerb.POST, 201, endpoint, successMarker); + runTestHandlingFlaky( + "DynamoDbConditionalPutItemWithHeuristicsEM", + "org.foo.spring.rest.dynamodb.DynamoDbConditionalPutItemWithHeuristicsEM", + 1000, + false, + args -> { + configureDynamoDbHeuristics(args, true); + setOption(args, "endpointFocus", endpoint); + setOption(args, "maxTestSize", "1"); + Solution guided = initAndRun(args); + assertHasAtLeastOne(guided, HttpVerb.POST, 201, endpoint, "SYNC PUT_ITEM SUCCESS"); }, 5); } From ea2ae291047f0c2e80e1430506ea7001e2c663d8 Mon Sep 17 00:00:00 2001 From: aschenzle Date: Fri, 16 Oct 2026 08:44:30 -0700 Subject: [PATCH 3/3] Addressing comments, adding tests for DynamoDbTableItemsCache --- .../db/dynamodb/DynamoDbTableItemsCache.java | 33 +++-- .../dynamodb/DynamoDbTableItemsCacheTest.java | 136 ++++++++++++++++++ 2 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCacheTest.java diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCache.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCache.java index 2e3e8245e1..4f4c1de360 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCache.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCache.java @@ -10,11 +10,22 @@ /** * Caches normalized DynamoDB table items loaded during one command evaluation batch. + *

+ * The cache maintains an unmodifiable list of item maps for each table accessed in the batch. + * Note that while the outer list and the per-item attribute maps are unmodifiable, the underlying + * {@link Object} attribute values (for instance, nested collections, maps, or byte arrays) are not + * guaranteed to be deeply immutable. */ final class DynamoDbTableItemsCache { private final DynamoDbTableDataAccessor tableDataAccessor; private final Object dynamoDbClient; + + /** + * Map from DynamoDB table name ({@link String}) to the list of items scanned from that table. + * Each item is represented as a {@link Map} where the key is the attribute name ({@link String}) + * and the value is the attribute value ({@link Object}). + */ private final Map>> itemsByTable = new HashMap<>(); /** @@ -33,7 +44,10 @@ final class DynamoDbTableItemsCache { * Returns the cached items for a table, scanning it on the first request in this batch. * * @param tableName table to read - * @return immutable normalized items for the table + * @return unmodifiable list of normalized items for the table, where each item is represented + * as an unmodifiable map of attribute name ({@link String}) to attribute value ({@link Object}). + * Note that the individual attribute value {@link Object}s are not guaranteed to be + * deeply immutable. */ List> getItems(String tableName) { String validTableName = validateTableName(tableName); @@ -46,24 +60,27 @@ List> getItems(String tableName) { } /** - * Checks that a table name can be used as a stable cache key. + * Checks that a table name can be used as a stable cache key and returns its trimmed representation. * * @param tableName candidate table name - * @return validated table name + * @return validated and trimmed table name */ private String validateTableName(String tableName) { Objects.requireNonNull(tableName, "DynamoDB table name cannot be null"); - if (tableName.trim().isEmpty()) { + String trimmed = tableName.trim(); + if (trimmed.isEmpty()) { throw new IllegalArgumentException("DynamoDB table name cannot be blank"); } - return tableName; + return trimmed; } /** - * Copies scanned items into an immutable structure and checks the cache invariants. + * Copies scanned items into unmodifiable structures and checks the cache invariants. * - * @param items scanned table items - * @return immutable table items + * @param items scanned table items, where each item is a {@link Map} of attribute name ({@link String}) + * to attribute value ({@link Object}) + * @return unmodifiable list of unmodifiable item maps. Note that individual attribute value + * {@link Object} instances within each map are not guaranteed to be deeply immutable. */ private List> validateItems(List> items) { Objects.requireNonNull(items, "DynamoDB table items cannot be null"); diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCacheTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCacheTest.java new file mode 100644 index 0000000000..b6158f5d60 --- /dev/null +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/dynamodb/DynamoDbTableItemsCacheTest.java @@ -0,0 +1,136 @@ +package org.evomaster.client.java.controller.internal.db.dynamodb; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ScanRequest; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests DynamoDB table item caching and validation invariants. + */ +public class DynamoDbTableItemsCacheTest { + + private static final String TABLE = "WorldCupPlayers"; + + private final DynamoDbTableDataAccessor accessor = new DynamoDbTableDataAccessor(); + + /** + * Verifies that items for a table are loaded on the first call and cached for subsequent calls. + */ + @Test + public void testCachesTableItemsAcrossMultipleRequests() { + DynamoDbClient client = mock(DynamoDbClient.class); + ScanResponse response = ScanResponse.builder() + .items(Collections.singletonList(player("Argentina", "Lionel Messi", 10))) + .build(); + when(client.scan(any(ScanRequest.class))).thenReturn(response); + + DynamoDbTableItemsCache cache = new DynamoDbTableItemsCache(accessor, client); + + List> first = cache.getItems(TABLE); + List> second = cache.getItems(TABLE); + + assertEquals(1, first.size()); + assertEquals("Lionel Messi", first.get(0).get("playerName")); + assertEquals(first, second); + verify(client, times(1)).scan(any(ScanRequest.class)); + } + + /** + * Verifies that whitespace-padded table names are trimmed and resolve to the same cache entry. + */ + @Test + public void testTrimsTableNameAndReusesCachedItems() { + DynamoDbClient client = mock(DynamoDbClient.class); + ScanResponse response = ScanResponse.builder() + .items(Collections.singletonList(player("Argentina", "Diego Maradona", 10))) + .build(); + when(client.scan(any(ScanRequest.class))).thenReturn(response); + + DynamoDbTableItemsCache cache = new DynamoDbTableItemsCache(accessor, client); + + List> direct = cache.getItems(TABLE); + List> padded = cache.getItems(" " + TABLE + " "); + + assertEquals(direct, padded); + verify(client, times(1)).scan(any(ScanRequest.class)); + } + + /** + * Verifies that null and blank table names are rejected. + */ + @Test + public void testRejectsNullOrBlankTableName() { + DynamoDbTableItemsCache cache = new DynamoDbTableItemsCache(accessor, mock(DynamoDbClient.class)); + + NullPointerException nullTable = assertThrows(NullPointerException.class, () -> cache.getItems(null)); + assertEquals("DynamoDB table name cannot be null", nullTable.getMessage()); + + IllegalArgumentException emptyTable = assertThrows(IllegalArgumentException.class, () -> cache.getItems("")); + assertEquals("DynamoDB table name cannot be blank", emptyTable.getMessage()); + + IllegalArgumentException blankTable = assertThrows(IllegalArgumentException.class, () -> cache.getItems(" ")); + assertEquals("DynamoDB table name cannot be blank", blankTable.getMessage()); + } + + /** + * Verifies that null accessor arguments in constructor are rejected. + */ + @Test + public void testRejectsNullConstructorArguments() { + NullPointerException exception = assertThrows(NullPointerException.class, + () -> new DynamoDbTableItemsCache(null, new Object())); + assertEquals("DynamoDB table data accessor cannot be null", exception.getMessage()); + } + + /** + * Verifies that cached item lists and maps cannot be mutated by callers. + */ + @Test + public void testReturnsUnmodifiableListAndMaps() { + DynamoDbClient client = mock(DynamoDbClient.class); + ScanResponse response = ScanResponse.builder() + .items(Collections.singletonList(player("Croatia", "Luka Modric", 10))) + .build(); + when(client.scan(any(ScanRequest.class))).thenReturn(response); + + DynamoDbTableItemsCache cache = new DynamoDbTableItemsCache(accessor, client); + List> items = cache.getItems(TABLE); + + Map newItem = new HashMap<>(); + newItem.put("country", "France"); + newItem.put("playerName", "Kylian Mbappe"); + assertThrows(UnsupportedOperationException.class, () -> items.add(newItem)); + assertThrows(UnsupportedOperationException.class, () -> items.get(0).put("goals", 5)); + } + + /** + * Creates one deterministic World Cup player item representation. + * + * @param country country name + * @param playerName player name + * @param squadNumber player squad number + * @return map representing player attributes as AttributeValues + */ + private Map player(String country, String playerName, int squadNumber) { + Map item = new HashMap<>(); + item.put("country", AttributeValue.builder().s(country).build()); + item.put("playerName", AttributeValue.builder().s(playerName).build()); + item.put("squadNumber", AttributeValue.builder().n(Integer.toString(squadNumber)).build()); + return item; + } +}