feat: paginate step functions executions

This commit is contained in:
Zakaria
2026-09-27 23:35:21 -04:00
parent 0aef7fb2d8
commit cede01969e
5 changed files with 564 additions and 15 deletions
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import static io.restassured.RestAssured.given;
@@ -331,6 +332,114 @@ class StepFunctionsExecutionHistoryIntegrationTest {
assertEquals(4L, events.get(4).path("previousEventId").asLong());
}
@Test
void listExecutions_pagesNewestFirstWithStatusFilterAndRejectsInvalidTokens() throws Exception {
String definition = """
{"StartAt":"Done","States":{"Done":{"Type":"Pass","End":true}}}
""";
String stateMachineArn = createStateMachine("execution-list-pagination-test", definition);
List<String> executionArns = new ArrayList<>();
for (int i = 0; i < 26; i++) {
executionArns.add(startExecution(stateMachineArn));
}
for (String executionArn : executionArns) {
waitForExecution(executionArn);
}
Response first = listExecutions(stateMachineArn, "SUCCEEDED", 10, null);
String secondToken = first.jsonPath().getString("nextToken");
Response second = listExecutions(stateMachineArn, "SUCCEEDED", 10, secondToken);
String thirdToken = second.jsonPath().getString("nextToken");
Response third = listExecutions(stateMachineArn, "SUCCEEDED", 10, thirdToken);
List<String> returnedArns = new ArrayList<>();
for (Response page : List.of(first, second, third)) {
returnedArns.addAll(page.jsonPath().getList("executions.executionArn"));
List<Number> dates = page.jsonPath().getList("executions.startDate");
for (int i = 1; i < dates.size(); i++) {
assertTrue(dates.get(i - 1).doubleValue() >= dates.get(i).doubleValue(), page.body().asString());
}
}
assertEquals(26, returnedArns.size());
assertEquals(26, new HashSet<>(returnedArns).size());
assertEquals(null, third.jsonPath().getString("nextToken"));
assertEquals(26, listExecutions(stateMachineArn, "SUCCEEDED", 0, null)
.jsonPath().getList("executions.executionArn").size());
given()
.header("X-Amz-Target", "AWSStepFunctions.ListExecutions")
.contentType(SFN_CONTENT_TYPE)
.body("""
{"stateMachineArn":"%s","statusFilter":"RUNNING","maxResults":10,"nextToken":"%s"}
""".formatted(stateMachineArn, secondToken))
.when()
.post("/")
.then()
.statusCode(400)
.body("__type", equalTo("InvalidToken"));
given()
.header("X-Amz-Target", "AWSStepFunctions.ListExecutions")
.contentType(SFN_CONTENT_TYPE)
.body("""
{"stateMachineArn":"%s","maxResults":1001}
""".formatted(stateMachineArn))
.when()
.post("/")
.then()
.statusCode(400)
.body("__type", equalTo("ValidationException"));
}
@Test
void getExecutionHistory_pagesAscendingAndReverseOrder() throws Exception {
StringBuilder states = new StringBuilder();
for (int i = 1; i <= 10; i++) {
states.append("\"P").append(i).append("\":{\"Type\":\"Pass\",");
if (i == 10) {
states.append("\"End\":true}");
} else {
states.append("\"Next\":\"P").append(i + 1).append("\"}");
}
if (i < 10) {
states.append(',');
}
}
String definition = "{\"StartAt\":\"P1\",\"States\":{" + states + "}}";
String stateMachineArn = createStateMachine("execution-history-pagination-test", definition);
String executionArn = startExecution(stateMachineArn);
waitForExecution(executionArn);
Response first = getExecutionHistoryPage(executionArn, false, 7, null);
Response second = getExecutionHistoryPage(executionArn, false, 7, first.jsonPath().getString("nextToken"));
Response third = getExecutionHistoryPage(executionArn, false, 7, second.jsonPath().getString("nextToken"));
Response fourth = getExecutionHistoryPage(executionArn, false, 7, third.jsonPath().getString("nextToken"));
List<Number> eventIds = new ArrayList<>();
for (Response page : List.of(first, second, third, fourth)) {
eventIds.addAll(page.jsonPath().getList("events.id"));
}
assertEquals(22, eventIds.size());
for (int i = 0; i < eventIds.size(); i++) {
assertEquals(i + 1L, eventIds.get(i).longValue());
}
Response reverse = getExecutionHistoryPage(executionArn, true, 7, null);
assertEquals(22L, reverse.jsonPath().getLong("events[0].id"));
assertEquals(16L, reverse.jsonPath().getLong("events[6].id"));
given()
.header("X-Amz-Target", "AWSStepFunctions.GetExecutionHistory")
.contentType(SFN_CONTENT_TYPE)
.body("""
{"executionArn":"%s","maxResults":7,"nextToken":"not-a-token"}
""".formatted(executionArn))
.when()
.post("/")
.then()
.statusCode(400)
.body("__type", equalTo("InvalidToken"));
}
private Response getExecutionHistory(String executionArn) {
Response response = given()
.header("X-Amz-Target", "AWSStepFunctions.GetExecutionHistory")
@@ -347,6 +456,35 @@ class StepFunctionsExecutionHistoryIntegrationTest {
return response;
}
private Response getExecutionHistoryPage(String executionArn, boolean reverseOrder, int maxResults,
String nextToken) {
String nextTokenField = nextToken == null ? "" : ",\"nextToken\":\"" + nextToken + "\"";
Response response = given()
.header("X-Amz-Target", "AWSStepFunctions.GetExecutionHistory")
.contentType(SFN_CONTENT_TYPE)
.body("""
{"executionArn":"%s","reverseOrder":%s,"maxResults":%d%s}
""".formatted(executionArn, reverseOrder, maxResults, nextTokenField))
.when()
.post("/");
response.then().statusCode(200);
return response;
}
private Response listExecutions(String stateMachineArn, String statusFilter, int maxResults, String nextToken) {
String nextTokenField = nextToken == null ? "" : ",\"nextToken\":\"" + nextToken + "\"";
Response response = given()
.header("X-Amz-Target", "AWSStepFunctions.ListExecutions")
.contentType(SFN_CONTENT_TYPE)
.body("""
{"stateMachineArn":"%s","statusFilter":"%s","maxResults":%d%s}
""".formatted(stateMachineArn, statusFilter, maxResults, nextTokenField))
.when()
.post("/");
response.then().statusCode(200);
return response;
}
private Response describeExecution(String executionArn) {
Response response = given()
.header("X-Amz-Target", "AWSStepFunctions.DescribeExecution")
@@ -0,0 +1,208 @@
package io.github.hectorvent.floci.services.stepfunctions;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.PaginatedResult;
import io.github.hectorvent.floci.core.common.RegionResolver;
import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.services.stepfunctions.model.Activity;
import io.github.hectorvent.floci.services.stepfunctions.model.Execution;
import io.github.hectorvent.floci.services.stepfunctions.model.HistoryEvent;
import io.github.hectorvent.floci.services.stepfunctions.model.MapRun;
import io.github.hectorvent.floci.services.stepfunctions.model.StateMachine;
import io.github.hectorvent.floci.services.stepfunctions.model.StateMachineAlias;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
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.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
class StepFunctionsListExecutionsPaginationTest {
private static final String ACCOUNT = "000000000000";
private static final String STATE_MACHINE_ARN =
"arn:aws:states:us-east-1:000000000000:stateMachine:test";
@Test
void continuesAfterNewExecutionsWithoutDuplicatingOrSkippingExistingExecutions() {
Fixture fixture = fixture();
List<String> existingArns = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
String arn = executionArn("existing-" + i);
existingArns.add(arn);
fixture.executions().put(arn, succeededExecution(arn, i));
}
PaginatedResult<Execution> first = fixture.service().listExecutions(
STATE_MACHINE_ARN, "SUCCEEDED", 2, null);
fixture.executions().put(executionArn("new-1"), succeededExecution(executionArn("new-1"), 7));
fixture.executions().put(executionArn("new-2"), succeededExecution(executionArn("new-2"), 6));
PaginatedResult<Execution> second = fixture.service().listExecutions(
STATE_MACHINE_ARN, "SUCCEEDED", 2, first.nextToken());
PaginatedResult<Execution> third = fixture.service().listExecutions(
STATE_MACHINE_ARN, "SUCCEEDED", 2, second.nextToken());
List<String> returned = new ArrayList<>();
for (PaginatedResult<Execution> page : List.of(first, second, third)) {
page.items().forEach(execution -> returned.add(execution.getExecutionArn()));
}
assertEquals(existingArns.size(), returned.stream().filter(existingArns::contains).count());
assertEquals(existingArns.size(), new HashSet<>(returned).stream().filter(existingArns::contains).count());
}
@Test
void rejectsTokenAfterTwentyFourHours() {
Fixture fixture = fixture();
fixture.executions().put(executionArn("one"), succeededExecution(executionArn("one"), 2));
fixture.executions().put(executionArn("two"), succeededExecution(executionArn("two"), 1));
String token = fixture.service().listExecutions(STATE_MACHINE_ARN, "SUCCEEDED", 1, null).nextToken();
fixture.clock().advanceSeconds(24 * 60 * 60 + 1);
AwsException exception = assertThrows(AwsException.class, () -> fixture.service().listExecutions(
STATE_MACHINE_ARN, "SUCCEEDED", 1, token));
assertEquals("InvalidToken", exception.getErrorCode());
}
@Test
void ordersSameMillisecondExecutionsByArnAcrossPages() {
Fixture fixture = fixture();
List<String> expected = new ArrayList<>();
for (int i = 0; i < 12; i++) {
String arn = executionArn("same-millisecond-%02d".formatted(i));
expected.add(arn);
Execution execution = succeededExecution(arn, 100 - i);
execution.setStartDate(42);
fixture.executions().put(arn, execution);
}
expected.sort(Comparator.reverseOrder());
PaginatedResult<Execution> first = fixture.service().listExecutions(STATE_MACHINE_ARN, "SUCCEEDED", 4, null);
PaginatedResult<Execution> second = fixture.service().listExecutions(
STATE_MACHINE_ARN, "SUCCEEDED", 4, first.nextToken());
PaginatedResult<Execution> third = fixture.service().listExecutions(
STATE_MACHINE_ARN, "SUCCEEDED", 4, second.nextToken());
List<String> returned = new ArrayList<>();
for (PaginatedResult<Execution> page : List.of(first, second, third)) {
page.items().forEach(execution -> returned.add(execution.getExecutionArn()));
}
assertEquals(expected, returned);
}
@Test
void reverseHistoryPagingDoesNotShiftWhenRunningExecutionAppendsEvents() {
Fixture fixture = fixture();
String executionArn = executionArn("running-history");
Execution execution = new Execution();
execution.setExecutionArn(executionArn);
execution.setStateMachineArn(STATE_MACHINE_ARN);
execution.setStatus("RUNNING");
execution.setHistory(history(10));
fixture.executions().put(executionArn, execution);
PaginatedResult<HistoryEvent> first = fixture.service().getExecutionHistory(
executionArn, true, true, 3, null);
List<HistoryEvent> liveHistory = fixture.service().getExecutionHistory(executionArn);
liveHistory.add(historyEvent(11));
liveHistory.add(historyEvent(12));
PaginatedResult<HistoryEvent> second = fixture.service().getExecutionHistory(
executionArn, true, true, 3, first.nextToken());
PaginatedResult<HistoryEvent> third = fixture.service().getExecutionHistory(
executionArn, true, true, 3, second.nextToken());
PaginatedResult<HistoryEvent> fourth = fixture.service().getExecutionHistory(
executionArn, true, true, 3, third.nextToken());
List<Long> returned = new ArrayList<>();
for (PaginatedResult<HistoryEvent> page : List.of(first, second, third, fourth)) {
page.items().forEach(event -> returned.add(event.getId()));
}
assertEquals(List.of(10L, 9L, 8L, 7L, 6L, 5L, 4L, 3L, 2L, 1L), returned);
}
private static Fixture fixture() {
AccountAwareStorageBackend<Execution> executions = AccountAwareStorageBackend.inMemory(ACCOUNT);
StorageFactory factory = mock(StorageFactory.class);
doReturn(AccountAwareStorageBackend.<StateMachine>inMemory(ACCOUNT))
.doReturn(executions)
.doReturn(AccountAwareStorageBackend.<Activity>inMemory(ACCOUNT))
.doReturn(AccountAwareStorageBackend.<MapRun>inMemory(ACCOUNT))
.doReturn(AccountAwareStorageBackend.<StateMachineAlias>inMemory(ACCOUNT))
.when(factory).create(anyString(), anyString(), any());
MutableClock clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
StepFunctionsService service = new StepFunctionsService(factory, new RegionResolver("us-east-1", ACCOUNT),
null, new ObjectMapper(), mock(SfnMockLoader.class), clock);
return new Fixture(service, executions, clock);
}
private static String executionArn(String name) {
return "arn:aws:states:us-east-1:000000000000:execution:test:" + name;
}
private static Execution succeededExecution(String arn, double stopDate) {
Execution execution = new Execution();
execution.setExecutionArn(arn);
execution.setStateMachineArn(STATE_MACHINE_ARN);
execution.setStatus("SUCCEEDED");
execution.setStopDate(stopDate);
return execution;
}
private static List<HistoryEvent> history(int count) {
List<HistoryEvent> events = new ArrayList<>();
for (int i = 1; i <= count; i++) {
events.add(historyEvent(i));
}
return events;
}
private static HistoryEvent historyEvent(long id) {
HistoryEvent event = new HistoryEvent();
event.setId(id);
return event;
}
private record Fixture(StepFunctionsService service, AccountAwareStorageBackend<Execution> executions,
MutableClock clock) {
}
private static final class MutableClock extends Clock {
private Instant instant;
private MutableClock(Instant instant) {
this.instant = instant;
}
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return instant;
}
private void advanceSeconds(long seconds) {
instant = instant.plusSeconds(seconds);
}
}
}