Skip to content

Commit 83b2ca9

Browse files
authored
test: cover suspended chained child in OTel (#824)
* test: cover suspended chained child in OTel * ci: default manual OTel runs to case 27 catalog * ci: use conformance main after catalog merge
1 parent 503996c commit 83b2ca9

6 files changed

Lines changed: 315 additions & 4 deletions

File tree

‎.github/workflows/otel-conformance-tests.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ on:
4949
conformance_test_ref:
5050
description: Optional conformance test commit SHA or branch name
5151
required: false
52-
default: "c32b40f5548f4776b7d533a76838e02358bc66d3"
52+
default: "main"
5353
type: string
5454

5555
permissions: {}
@@ -69,15 +69,15 @@ jobs:
6969
actions: write
7070
contents: read
7171
id-token: write
72-
uses: aws/aws-durable-execution-conformance-tests/.github/workflows/opentelemetry-orchestrator.yml@04f194f44a5136074dfb17c8add1d75b24055cad
72+
uses: aws/aws-durable-execution-conformance-tests/.github/workflows/opentelemetry-orchestrator.yml@main
7373
with:
74-
case_count: 26
74+
case_count: 27
7575
runs_on: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'ubuntu-latest' || format('codebuild-github-actions-runner-{0}-{1}', github.run_id, github.run_attempt) }}
7676
language: java
7777
resource_prefix: j
7878
sdk_repository: aws/aws-durable-execution-sdk-java
7979
sdk_ref: ${{ github.event.pull_request.head.sha || github.sha }}
80-
conformance_test_ref: ${{ inputs.conformance_test_ref || 'c32b40f5548f4776b7d533a76838e02358bc66d3' }}
80+
conformance_test_ref: ${{ inputs.conformance_test_ref || 'main' }}
8181
checkout_sdk: true
8282
# Build the handlers from this repo's checked-out module instead of the conformance repo's
8383
# bundled examples/java. Path is relative to the conformance workspace where the SDK is

‎conformance-tests-otel/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,9 @@ For case 25, Java reports no plugin error details for an entirely empty error
106106
container. The persisted container, callback failure, and caller error stay
107107
unchanged. Any present type, message, data, or stack-trace field remains an error
108108
detail, including an empty string or an explicitly supplied empty stack list.
109+
110+
Case 27 (`chained-invoke-suspend`) invokes a child that performs a one-second
111+
durable wait and returns the original payload after resuming. Both views
112+
check two caller invocations, two child invocations and the child wait's
113+
parentage and continuation links. It replaces the suspended-child scenario
114+
formerly exercised by the short mode of `otel-long-running-4`.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.conformance.otel;
4+
5+
import java.util.Map;
6+
import software.amazon.lambda.durable.DurableContext;
7+
import software.amazon.lambda.durable.TypeToken;
8+
9+
/** Chained invoke with child suspension for OTel view case 27. */
10+
public final class Otel27ChainedInvokeSuspend extends OtelConformanceHandler<Map<String, Object>> {
11+
12+
@Override
13+
public Map<String, Object> handleRequest(Map<String, Object> event, DurableContext context) {
14+
requireScenario(event, "chained-invoke-suspend");
15+
return context.invoke(
16+
"otel-suspended-invoke",
17+
System.getenv("OTEL_INVOKE_TARGET_FUNCTION_NAME"),
18+
event,
19+
new TypeToken<Map<String, Object>>() {});
20+
}
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.conformance.otel;
4+
5+
import java.time.Duration;
6+
import java.util.Map;
7+
import software.amazon.lambda.durable.DurableContext;
8+
9+
/** Suspending child for OTel view case 27. */
10+
public final class Otel27InvokeTarget extends OtelConformanceHandler<Map<String, Object>> {
11+
12+
@Override
13+
public Map<String, Object> handleRequest(Map<String, Object> event, DurableContext context) {
14+
requireScenario(event, "chained-invoke-suspend");
15+
context.wait("otel-suspended-target-wait", Duration.ofSeconds(1));
16+
return event;
17+
}
18+
}

‎conformance-tests-otel/template.yaml‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,3 +738,51 @@ Resources:
738738
Environment:
739739
Variables:
740740
OTEL_PLUGIN_MODE: execution
741+
742+
Otel27ChainedInvokeSuspend:
743+
Type: AWS::Serverless::Function
744+
TestingMetadata:
745+
TestDescription:
746+
- otel-invocation-27
747+
Properties:
748+
CodeUri: .
749+
FunctionName: !Sub "${AWS::StackName}-otel-invocation-27"
750+
Handler: software.amazon.lambda.durable.conformance.otel.Otel27ChainedInvokeSuspend
751+
Role: !Ref LambdaExecutionRoleArn
752+
Environment:
753+
Variables:
754+
OTEL_INVOKE_TARGET_FUNCTION_NAME: !Sub "${Otel27InvokeTarget.Arn}:$LATEST"
755+
756+
Otel27InvokeTarget:
757+
Type: AWS::Serverless::Function
758+
Properties:
759+
CodeUri: .
760+
FunctionName: !Sub "${AWS::StackName}-otel-invocation-27-target"
761+
Handler: software.amazon.lambda.durable.conformance.otel.Otel27InvokeTarget
762+
Role: !Ref LambdaExecutionRoleArn
763+
764+
OtelExecution27ChainedInvokeSuspend:
765+
Type: AWS::Serverless::Function
766+
TestingMetadata:
767+
TestDescription:
768+
- otel-execution-27
769+
Properties:
770+
CodeUri: .
771+
FunctionName: !Sub "${AWS::StackName}-otel-execution-27"
772+
Handler: software.amazon.lambda.durable.conformance.otel.Otel27ChainedInvokeSuspend
773+
Role: !Ref LambdaExecutionRoleArn
774+
Environment:
775+
Variables:
776+
OTEL_PLUGIN_MODE: execution
777+
OTEL_INVOKE_TARGET_FUNCTION_NAME: !Sub "${OtelExecution27InvokeTarget.Arn}:$LATEST"
778+
779+
OtelExecution27InvokeTarget:
780+
Type: AWS::Serverless::Function
781+
Properties:
782+
CodeUri: .
783+
FunctionName: !Sub "${AWS::StackName}-otel-execution-27-target"
784+
Handler: software.amazon.lambda.durable.conformance.otel.Otel27InvokeTarget
785+
Role: !Ref LambdaExecutionRoleArn
786+
Environment:
787+
Variables:
788+
OTEL_PLUGIN_MODE: execution
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.otel;
4+
5+
import static org.junit.jupiter.api.Assertions.*;
6+
import static software.amazon.lambda.durable.otel.SpanAttributes.*;
7+
8+
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import io.opentelemetry.api.trace.StatusCode;
10+
import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
11+
import io.opentelemetry.sdk.trace.SdkTracerProvider;
12+
import io.opentelemetry.sdk.trace.data.SpanData;
13+
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
14+
import java.net.URL;
15+
import java.net.URLClassLoader;
16+
import java.nio.file.Files;
17+
import java.nio.file.Path;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.concurrent.CopyOnWriteArrayList;
21+
import java.util.concurrent.ExecutorService;
22+
import java.util.concurrent.Executors;
23+
import java.util.concurrent.TimeUnit;
24+
import javax.tools.ToolProvider;
25+
import org.junit.jupiter.api.Timeout;
26+
import org.junit.jupiter.api.io.TempDir;
27+
import org.junit.jupiter.params.ParameterizedTest;
28+
import org.junit.jupiter.params.provider.ValueSource;
29+
import software.amazon.lambda.durable.DurableConfig;
30+
import software.amazon.lambda.durable.DurableHandler;
31+
import software.amazon.lambda.durable.TypeToken;
32+
import software.amazon.lambda.durable.model.ExecutionStatus;
33+
import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
34+
import software.amazon.lambda.durable.plugin.DurableExecutionPluginFactory;
35+
import software.amazon.lambda.durable.plugin.InvocationEndInfo;
36+
import software.amazon.lambda.durable.plugin.InvocationStatus;
37+
import software.amazon.lambda.durable.testing.LocalDurableTestRunner;
38+
39+
@Timeout(20)
40+
class SuspendedChildConformanceHandlerTest {
41+
private static final String INVOKE = "otel-suspended-invoke";
42+
private static final String WAIT = "otel-suspended-target-wait";
43+
44+
@TempDir
45+
Path directory;
46+
47+
@ParameterizedTest
48+
@ValueSource(booleans = {false, true})
49+
void actualChildSuspendsAndResumesBeforeCallerCompletes(boolean executionView) throws Exception {
50+
DeterministicIdGenerator.clearSharedStateForTest();
51+
DurableSamplingDecision.clearSharedStateForTest();
52+
OtelPluginAutoConfigurationState.resetInstalledForTest();
53+
var classes = compileHandler();
54+
var workers = Executors.newFixedThreadPool(4);
55+
var exporter = InMemorySpanExporter.create();
56+
try (var loader = new URLClassLoader(
57+
new URL[] {classes.toUri().toURL()}, getClass().getClassLoader())) {
58+
verifyHandlers(executionView, loader, exporter, workers);
59+
} finally {
60+
workers.shutdownNow();
61+
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
62+
exporter.close();
63+
DeterministicIdGenerator.clearSharedStateForTest();
64+
DurableSamplingDecision.clearSharedStateForTest();
65+
OtelPluginAutoConfigurationState.resetInstalledForTest();
66+
}
67+
}
68+
69+
private void verifyHandlers(
70+
boolean executionView, URLClassLoader loader, InMemorySpanExporter exporter, ExecutorService workers)
71+
throws Exception {
72+
var ends = new CopyOnWriteArrayList<InvocationEndInfo>();
73+
var childEnds = new CopyOnWriteArrayList<InvocationEndInfo>();
74+
var handler = loadHandler(loader, "Otel27ChainedInvokeSuspend");
75+
var childHandler = loadHandler(loader, "Otel27InvokeTarget");
76+
var runner = LocalDurableTestRunner.create(
77+
new TypeToken<Map<String, Object>>() {},
78+
handler::handleRequest,
79+
configuration(executionView, exporter, workers, ends));
80+
var child = LocalDurableTestRunner.create(
81+
new TypeToken<Map<String, Object>>() {},
82+
childHandler::handleRequest,
83+
configuration(executionView, exporter, workers, childEnds));
84+
verifyExecution(runner, child);
85+
for (var invocations : List.of(ends, childEnds)) {
86+
assertEquals(
87+
List.of(InvocationStatus.PENDING, InvocationStatus.SUCCEEDED),
88+
invocations.stream()
89+
.map(InvocationEndInfo::invocationStatus)
90+
.toList());
91+
}
92+
verifyWaitTelemetry(executionView, exporter);
93+
assertEquals(1, terminalInvokes(exporter).size());
94+
}
95+
96+
@SuppressWarnings("unchecked")
97+
private DurableHandler<Map<String, Object>, Map<String, Object>> loadHandler(URLClassLoader loader, String name)
98+
throws Exception {
99+
return (DurableHandler<Map<String, Object>, Map<String, Object>>)
100+
loader.loadClass("software.amazon.lambda.durable.conformance.otel." + name)
101+
.getConstructor()
102+
.newInstance();
103+
}
104+
105+
private void verifyExecution(
106+
LocalDurableTestRunner<Map<String, Object>, Map<String, Object>> runner,
107+
LocalDurableTestRunner<Map<String, Object>, Map<String, Object>> child)
108+
throws Exception {
109+
Map<String, Object> input = Map.of("scenario", "chained-invoke-suspend", "nested", Map.of("value", 42));
110+
var first = runner.run(input);
111+
assertEquals(ExecutionStatus.PENDING, first.getStatus());
112+
var invokeId = first.getOperation(INVOKE).getId();
113+
var childFirst = child.run(input);
114+
assertEquals(ExecutionStatus.PENDING, childFirst.getStatus());
115+
var waitId = childFirst.getOperation(WAIT).getId();
116+
child.advanceTime();
117+
var childLast = child.run(input);
118+
assertEquals(ExecutionStatus.SUCCEEDED, childLast.getStatus());
119+
assertEquals(waitId, childLast.getOperation(WAIT).getId());
120+
var childResult = childLast.getResult(new TypeToken<Map<String, Object>>() {});
121+
assertEquals(input, childResult);
122+
runner.completeChainedInvoke(INVOKE, new ObjectMapper().writeValueAsString(childResult));
123+
var last = runner.run(input);
124+
assertEquals(ExecutionStatus.SUCCEEDED, last.getStatus());
125+
assertEquals(input, last.getResult(new TypeToken<Map<String, Object>>() {}));
126+
assertEquals(invokeId, last.getOperation(INVOKE).getId());
127+
}
128+
129+
private void verifyWaitTelemetry(boolean executionView, InMemorySpanExporter exporter) {
130+
var waits = exporter.getFinishedSpanItems().stream()
131+
.filter(span -> WAIT.equals(span.getName()))
132+
.toList();
133+
assertEquals(executionView ? 1 : 2, waits.size());
134+
assertEquals(
135+
1,
136+
waits.stream()
137+
.map(span -> span.getAttributes().get(DURABLE_OPERATION_ID))
138+
.distinct()
139+
.count());
140+
var completed = waits.get(waits.size() - 1);
141+
assertEquals(StatusCode.OK, completed.getStatus().getStatusCode());
142+
if (executionView) {
143+
var childArn = completed.getAttributes().get(DURABLE_EXECUTION_ARN);
144+
var resumed = exporter.getFinishedSpanItems().stream()
145+
.filter(span -> "Invocation".equals(span.getName()))
146+
.filter(span -> childArn.equals(span.getAttributes().get(DURABLE_EXECUTION_ARN)))
147+
.filter(span -> Boolean.FALSE.equals(span.getAttributes().get(DURABLE_FIRST_INVOCATION)))
148+
.findFirst()
149+
.orElseThrow();
150+
assertEquals(
151+
resumed.getSpanId(),
152+
completed.getLinks().get(0).getSpanContext().getSpanId());
153+
} else {
154+
assertEquals(
155+
waits.get(0).getSpanId(),
156+
completed.getLinks().get(0).getSpanContext().getSpanId());
157+
}
158+
}
159+
160+
private DurableConfig configuration(
161+
boolean executionView,
162+
InMemorySpanExporter exporter,
163+
ExecutorService workers,
164+
List<InvocationEndInfo> ends) {
165+
var provider = SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter));
166+
var options = OtelPluginConfig.builder()
167+
.contextExtractor(() -> null)
168+
.enableMdc(false)
169+
.build();
170+
DurableExecutionPluginFactory otel = executionView
171+
? ExecutionOtelPlugin.factory(provider, options)
172+
: InvocationOtelPlugin.factory(provider, options);
173+
var observer = new DurableExecutionPlugin() {
174+
@Override
175+
public void onInvocationEnd(InvocationEndInfo info) {
176+
ends.add(info);
177+
}
178+
};
179+
return DurableConfig.builder()
180+
.withExecutorService(workers)
181+
.withPlugins(otel, ignored -> observer)
182+
.build();
183+
}
184+
185+
private List<SpanData> terminalInvokes(InMemorySpanExporter exporter) {
186+
return exporter.getFinishedSpanItems().stream()
187+
.filter(span -> INVOKE.equals(span.getName()))
188+
.filter(span -> "SUCCEEDED".equals(span.getAttributes().get(DURABLE_OPERATION_STATUS)))
189+
.toList();
190+
}
191+
192+
private Path compileHandler() throws Exception {
193+
var root = Path.of(System.getProperty("user.dir")).toAbsolutePath();
194+
while (root != null && !Files.isDirectory(root.resolve("conformance-tests-otel"))) root = root.getParent();
195+
assertNotNull(root, "repository root containing deployment handler sources");
196+
var source =
197+
root.resolve("conformance-tests-otel/src/main/java/software/amazon/lambda/durable/conformance/otel");
198+
var classes = Files.createDirectories(directory.resolve("classes"));
199+
var compiler = ToolProvider.getSystemJavaCompiler();
200+
assertNotNull(compiler);
201+
assertEquals(
202+
0,
203+
compiler.run(
204+
null,
205+
null,
206+
null,
207+
"--release",
208+
"17",
209+
"-cp",
210+
System.getProperty("java.class.path"),
211+
"-d",
212+
classes.toString(),
213+
source.resolve("OtelConformanceHandler.java").toString(),
214+
source.resolve("Otel27ChainedInvokeSuspend.java").toString(),
215+
source.resolve("Otel27InvokeTarget.java").toString()));
216+
return classes;
217+
}
218+
}

0 commit comments

Comments
 (0)