feat: paginate step functions executions
This commit is contained in:
+138
@@ -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")
|
||||
|
||||
+208
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user