diff --git a/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java new file mode 100644 index 000000000..d6cfc7aef --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java @@ -0,0 +1,194 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package plugin; + +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.config.StepConfig; +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.InvocationInfo; +import software.amazon.lambda.durable.plugin.UserFunctionEndInfo; +import software.amazon.lambda.durable.plugin.UserFunctionStartInfo; +import software.amazon.lambda.durable.retry.JitterStrategy; +import software.amazon.lambda.durable.retry.RetryStrategies; + +/** + * 10-21: Attempt hook info field shape (CANONICAL DUMP). + * + *

A single step named {@code "flaky"} that throws on attempt 1 and succeeds on attempt 2 using the SDK's real + * exponential-backoff retry strategy (max attempts 3, ~1s delay), returning {@code "ok"}. The instrumentation plugin + * (filtering to step-type attempts) emits ONE single-line JSON record per per-attempt (user-function) hook event: a + * canonical dump of that hook's OWN info parameter, every exposed component mapped to its canonical camelCase name, + * null / unexposed fields OMITTED. + * + *

Java's {@link UserFunctionStartInfo} exposes id/name/type/subType/parentId/startTimestamp/isReplayingChildren/ + * attempt (no endTimestamp/isReplay/outcome/error at start). Java's {@link UserFunctionEndInfo} adds endTimestamp, the + * {@code succeeded} boolean (presented as the shared {@code outcome} SUCCEEDED/FAILED token) and {@code error}. This is + * the richest attempt surface — every probed field is present, so the attempt assertions are expected to pass. + */ +@SuppressWarnings("deprecation") +public class PluginAttemptInfoShape extends DurableHandler { + + @Override + protected DurableConfig createConfiguration() { + return DurableConfig.builder().withPlugins(new AttemptShapePlugin()).build(); + } + + @Override + public String handleRequest(Object input, DurableContext context) { + return context.step( + "flaky", + String.class, + stepCtx -> { + // Fail on the first attempt, succeed on the second, using the SDK's built-in 1-based attempt + // number. + if (stepCtx.getAttempt() < 2) { + throw new RuntimeException("Attempt " + stepCtx.getAttempt() + " failed"); + } + return "ok"; + }, + StepConfig.builder() + .retryStrategy(RetryStrategies.exponentialBackoff( + 3, Duration.ofSeconds(1), Duration.ofSeconds(10), 1.0, JitterStrategy.NONE)) + .build()); + } + + private static final class AttemptShapePlugin implements DurableExecutionPlugin { + private volatile String executionArn; + + @Override + public void onInvocationStart(InvocationInfo info) { + this.executionArn = info.durableExecutionArn(); + } + + @Override + public void onUserFunctionStart(UserFunctionStartInfo info) { + if (!PluginSupport.isStep(info.type()) || info.attempt() == null) { + return; + } + new Rec("attempt-start") + .str("id", info.id()) + .str("name", info.name()) + .str("type", Rec.upper(info.type())) + .str("subType", info.subType()) + .str("parentId", info.parentId()) + .num("attempt", info.attempt()) + .time("startTimestamp", info.startTimestamp()) + .bool("isReplayingChildren", info.isReplayingChildren()) + .emit(executionArn); + } + + @Override + public void onUserFunctionEnd(UserFunctionEndInfo info) { + if (!PluginSupport.isStep(info.type()) || info.attempt() == null) { + return; + } + new Rec("attempt-end") + .str("id", info.id()) + .str("name", info.name()) + .str("type", Rec.upper(info.type())) + .str("subType", info.subType()) + .str("parentId", info.parentId()) + .num("attempt", info.attempt()) + .time("startTimestamp", info.startTimestamp()) + .time("endTimestamp", info.endTimestamp()) + .bool("isReplayingChildren", info.isReplayingChildren()) + .str("outcome", info.succeeded() ? "SUCCEEDED" : "FAILED") + .str("error", Rec.msg(info.error())) + .emit(executionArn); + } + } + + /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */ + private static final class Rec { + private final StringBuilder sb = new StringBuilder("{"); + + Rec(String hook) { + raw("plugin", "\"CONFPLUGIN\""); + raw("hook", "\"" + hook + "\""); + } + + Rec str(String key, String value) { + if (value != null) { + raw(key, quote(value)); + } + return this; + } + + Rec num(String key, Integer value) { + if (value != null) { + raw(key, value.toString()); + } + return this; + } + + Rec bool(String key, boolean value) { + raw(key, value ? "true" : "false"); + return this; + } + + Rec time(String key, Instant value) { + if (value != null) { + raw(key, quote(value.toString())); + } + return this; + } + + private void raw(String key, String jsonValue) { + if (sb.length() > 1) { + sb.append(", "); + } + sb.append('"').append(key).append("\": ").append(jsonValue); + } + + void emit(String executionArn) { + System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}')); + } + + static String upper(String s) { + return s == null ? null : s.toUpperCase(Locale.ROOT); + } + + static String msg(Throwable t) { + if (t == null) { + return null; + } + return t.getMessage() != null ? t.getMessage() : t.toString(); + } + + static String quote(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': + b.append("\\\""); + break; + case '\\': + b.append("\\\\"); + break; + case '\n': + b.append("\\n"); + break; + case '\r': + b.append("\\r"); + break; + case '\t': + b.append("\\t"); + break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.append('"').toString(); + } + } +} diff --git a/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java new file mode 100644 index 000000000..c306c80da --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java @@ -0,0 +1,149 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package plugin; + +import java.time.Duration; +import java.time.Instant; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.InvocationEndInfo; +import software.amazon.lambda.durable.plugin.InvocationInfo; +import software.amazon.lambda.durable.plugin.InvocationStatus; + +/** + * 10-19: Invocation hook info field shape (CANONICAL DUMP). + * + *

A single 2-second wait that then returns {@code "done-" + input}. The instrumentation plugin emits ONE single-line + * JSON record per invocation hook event: a canonical dump of that hook's OWN info parameter, every exposed component + * mapped one-to-one to its canonical camelCase name, null / unexposed fields OMITTED (a missing key fails its assertion + * — the parity signal). + * + *

Java's {@link InvocationInfo} exposes only {@code requestId}, {@code executionStartTime} (→ + * {@code executionStartTimestamp}) and {@code isFirstInvocation}; it does NOT expose the execution input, the + * operations map, or an externally-updated-operations collection, so {@code executionInput}, {@code operationsCount} + * and {@code updatedOperationsCount} are absent. Java's {@link InvocationEndInfo} exposes {@code isFirstInvocation}, + * {@code invocationStatus} (→ {@code status}) and {@code executionError}; it does NOT expose the execution input or the + * final result, so {@code executionInput} and {@code executionResult} are absent. The single derived scalar + * {@code terminal} := status in (SUCCEEDED, FAILED). Those omissions are the honest reds the requirement produces. + */ +@SuppressWarnings("deprecation") +public class PluginInvocationInfoShape extends DurableHandler { + + @Override + protected DurableConfig createConfiguration() { + return DurableConfig.builder().withPlugins(new InvocationShapePlugin()).build(); + } + + @Override + public String handleRequest(String input, DurableContext context) { + context.wait(null, Duration.ofSeconds(2)); + return "done-" + input; + } + + private static final class InvocationShapePlugin implements DurableExecutionPlugin { + private volatile String executionArn; + + @Override + public void onInvocationStart(InvocationInfo info) { + this.executionArn = info.durableExecutionArn(); + new Rec("invocation-start") + .bool("isFirstInvocation", info.isFirstInvocation()) + .str("requestId", info.requestId()) + .time("executionStartTimestamp", info.executionStartTime()) + .emit(executionArn); + } + + @Override + public void onInvocationEnd(InvocationEndInfo info) { + InvocationStatus status = info.invocationStatus(); + boolean terminal = status == InvocationStatus.SUCCEEDED || status == InvocationStatus.FAILED; + new Rec("invocation-end") + .bool("isFirstInvocation", info.isFirstInvocation()) + .str("requestId", info.requestId()) + .str("status", status == null ? null : status.name()) + .bool("terminal", terminal) + .str("executionError", Rec.msg(info.executionError())) + .emit(executionArn); + } + } + + /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */ + private static final class Rec { + private final StringBuilder sb = new StringBuilder("{"); + + Rec(String hook) { + raw("plugin", "\"CONFPLUGIN\""); + raw("hook", "\"" + hook + "\""); + } + + Rec str(String key, String value) { + if (value != null) { + raw(key, quote(value)); + } + return this; + } + + Rec bool(String key, boolean value) { + raw(key, value ? "true" : "false"); + return this; + } + + Rec time(String key, Instant value) { + if (value != null) { + raw(key, quote(value.toString())); + } + return this; + } + + private void raw(String key, String jsonValue) { + if (sb.length() > 1) { + sb.append(", "); + } + sb.append('"').append(key).append("\": ").append(jsonValue); + } + + void emit(String executionArn) { + System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}')); + } + + static String msg(Throwable t) { + if (t == null) { + return null; + } + return t.getMessage() != null ? t.getMessage() : t.toString(); + } + + static String quote(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': + b.append("\\\""); + break; + case '\\': + b.append("\\\\"); + break; + case '\n': + b.append("\\n"); + break; + case '\r': + b.append("\\r"); + break; + case '\t': + b.append("\\t"); + break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.append('"').toString(); + } + } +} diff --git a/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java new file mode 100644 index 000000000..7d73a6093 --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java @@ -0,0 +1,169 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package plugin; + +import java.time.Instant; +import java.util.Locale; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.InvocationInfo; +import software.amazon.lambda.durable.plugin.OperationChangeInfo; +import software.amazon.lambda.durable.plugin.OperationChangeItemInfo; + +/** + * 10-22: Operation-change hook info field shape (CANONICAL DUMP). + * + *

A single step named {@code "greet"} returning the constant {@code "task-a"}. For each step-type operation in the + * change info's updated-operations delta the instrumentation plugin emits ONE single-line JSON record: a canonical dump + * of that DELTA ITEM's OWN field surface, plus the hook-level fields {@code executionArn} (from the change info), + * {@code updatedOperationsCount}/{@code operationsCount} (map sizes) and the derived {@code inFullMap} := the same id + * also appears in the info's full operations map. Null / unexposed fields are OMITTED. + * + *

Java's {@link OperationChangeItemInfo} exposes id/name/type/subType/parentId/startTimestamp/endTimestamp/error/ + * status but does NOT expose the checkpointed serialized result, the attempt number, or a replay indicator, so + * {@code result}, {@code attempt} and {@code isReplay} are absent on each item — those omissions are the honest reds + * the requirement produces. + */ +@SuppressWarnings("deprecation") +public class PluginOperationChangeShape extends DurableHandler { + + @Override + protected DurableConfig createConfiguration() { + return DurableConfig.builder().withPlugins(new ChangeShapePlugin()).build(); + } + + @Override + public String handleRequest(Object input, DurableContext context) { + return context.step("greet", String.class, stepCtx -> "task-a"); + } + + private static final class ChangeShapePlugin implements DurableExecutionPlugin { + private volatile String executionArn; + + @Override + public void onInvocationStart(InvocationInfo info) { + this.executionArn = info.durableExecutionArn(); + } + + @Override + public void onOperationChange(OperationChangeInfo info) { + int updatedOperationsCount = info.updatedOperations().size(); + int operationsCount = info.operations().size(); + for (OperationChangeItemInfo item : info.updatedOperations().values()) { + if (!PluginSupport.isStepChange(item.type())) { + continue; + } + new Rec("operation-change") + .str("executionArn", info.durableExecutionArn()) + .num("updatedOperationsCount", updatedOperationsCount) + .num("operationsCount", operationsCount) + .bool("inFullMap", info.operations().containsKey(item.id())) + .str("id", item.id()) + .str("name", item.name()) + .str("type", Rec.upper(item.type())) + .str("subType", item.subType()) + .str("parentId", item.parentId()) + .str( + "status", + item.status() == null + ? null + : Rec.upper(item.status().toString())) + .time("startTimestamp", item.startTimestamp()) + .time("endTimestamp", item.endTimestamp()) + .str("error", Rec.msg(item.error())) + .emit(executionArn); + } + } + } + + /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */ + private static final class Rec { + private final StringBuilder sb = new StringBuilder("{"); + + Rec(String hook) { + raw("plugin", "\"CONFPLUGIN\""); + raw("hook", "\"" + hook + "\""); + } + + Rec str(String key, String value) { + if (value != null) { + raw(key, quote(value)); + } + return this; + } + + Rec num(String key, Integer value) { + if (value != null) { + raw(key, value.toString()); + } + return this; + } + + Rec bool(String key, boolean value) { + raw(key, value ? "true" : "false"); + return this; + } + + Rec time(String key, Instant value) { + if (value != null) { + raw(key, quote(value.toString())); + } + return this; + } + + private void raw(String key, String jsonValue) { + if (sb.length() > 1) { + sb.append(", "); + } + sb.append('"').append(key).append("\": ").append(jsonValue); + } + + void emit(String executionArn) { + System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}')); + } + + static String upper(String s) { + return s == null ? null : s.toUpperCase(Locale.ROOT); + } + + static String msg(Throwable t) { + if (t == null) { + return null; + } + return t.getMessage() != null ? t.getMessage() : t.toString(); + } + + static String quote(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': + b.append("\\\""); + break; + case '\\': + b.append("\\\\"); + break; + case '\n': + b.append("\\n"); + break; + case '\r': + b.append("\\r"); + break; + case '\t': + b.append("\\t"); + break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.append('"').toString(); + } + } +} diff --git a/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java b/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java new file mode 100644 index 000000000..71bfce0c0 --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java @@ -0,0 +1,176 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package plugin; + +import java.time.Instant; +import java.util.Locale; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.InvocationInfo; +import software.amazon.lambda.durable.plugin.OperationEndInfo; +import software.amazon.lambda.durable.plugin.OperationInfo; + +/** + * 10-20: Operation hook info field shape (CANONICAL DUMP). + * + *

A single step named {@code "greet"} returning the constant {@code "task-a"}. The instrumentation plugin (filtering + * to step-type operations) emits ONE single-line JSON record per operation hook event: a canonical dump of that hook's + * OWN info parameter, every exposed component mapped to its canonical camelCase name, null / unexposed fields OMITTED. + * + *

Java's {@link OperationInfo} (operation-start) exposes id/name/type/subType/parentId/startTimestamp/endTimestamp/ + * status/isReplay; at a LIVE first start {@code status}/{@code startTimestamp} may be unset and are simply omitted, and + * the record has no {@code attempt}/{@code result}/{@code error} at all. Java's {@link OperationEndInfo} + * (operation-end) adds {@code attempt} and {@code error} but does NOT expose the checkpointed serialized result, so + * {@code result} is absent on the end record — that omission is the honest red the requirement produces. + */ +@SuppressWarnings("deprecation") +public class PluginOperationInfoShape extends DurableHandler { + + @Override + protected DurableConfig createConfiguration() { + return DurableConfig.builder().withPlugins(new OperationShapePlugin()).build(); + } + + @Override + public String handleRequest(Object input, DurableContext context) { + return context.step("greet", String.class, stepCtx -> "task-a"); + } + + private static final class OperationShapePlugin implements DurableExecutionPlugin { + private volatile String executionArn; + + @Override + public void onInvocationStart(InvocationInfo info) { + this.executionArn = info.durableExecutionArn(); + } + + @Override + public void onOperationStart(OperationInfo info) { + if (!PluginSupport.isStep(info.type())) { + return; + } + new Rec("operation-start") + .str("id", info.id()) + .str("name", info.name()) + .str("type", Rec.upper(info.type())) + .str("subType", info.subType()) + .str("parentId", info.parentId()) + .str("status", Rec.upper(info.status())) + .time("startTimestamp", info.startTimestamp()) + .time("endTimestamp", info.endTimestamp()) + .bool("isReplay", info.isReplay()) + .emit(executionArn); + } + + @Override + public void onOperationEnd(OperationEndInfo info) { + if (!PluginSupport.isStep(info.type())) { + return; + } + new Rec("operation-end") + .str("id", info.id()) + .str("name", info.name()) + .str("type", Rec.upper(info.type())) + .str("subType", info.subType()) + .str("parentId", info.parentId()) + .str("status", Rec.upper(info.status())) + .time("startTimestamp", info.startTimestamp()) + .time("endTimestamp", info.endTimestamp()) + .num("attempt", info.attempt()) + .bool("isReplay", info.isReplay()) + .str("error", Rec.msg(info.error())) + .emit(executionArn); + } + } + + /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */ + private static final class Rec { + private final StringBuilder sb = new StringBuilder("{"); + + Rec(String hook) { + raw("plugin", "\"CONFPLUGIN\""); + raw("hook", "\"" + hook + "\""); + } + + Rec str(String key, String value) { + if (value != null) { + raw(key, quote(value)); + } + return this; + } + + Rec num(String key, Integer value) { + if (value != null) { + raw(key, value.toString()); + } + return this; + } + + Rec bool(String key, boolean value) { + raw(key, value ? "true" : "false"); + return this; + } + + Rec time(String key, Instant value) { + if (value != null) { + raw(key, quote(value.toString())); + } + return this; + } + + private void raw(String key, String jsonValue) { + if (sb.length() > 1) { + sb.append(", "); + } + sb.append('"').append(key).append("\": ").append(jsonValue); + } + + void emit(String executionArn) { + System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}')); + } + + static String upper(String s) { + return s == null ? null : s.toUpperCase(Locale.ROOT); + } + + static String msg(Throwable t) { + if (t == null) { + return null; + } + return t.getMessage() != null ? t.getMessage() : t.toString(); + } + + static String quote(String s) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': + b.append("\\\""); + break; + case '\\': + b.append("\\\\"); + break; + case '\n': + b.append("\\n"); + break; + case '\r': + b.append("\\r"); + break; + case '\t': + b.append("\\t"); + break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.append('"').toString(); + } + } +} diff --git a/conformance-tests/template_plugin.yaml b/conformance-tests/template_plugin.yaml index ecc8cac17..ebc680135 100644 --- a/conformance-tests/template_plugin.yaml +++ b/conformance-tests/template_plugin.yaml @@ -304,3 +304,67 @@ Resources: DurableConfig: RetentionPeriodInDays: 7 ExecutionTimeout: 300 + + PluginInvocationInfoShape: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["10-19"] + Properties: + CodeUri: . + Handler: plugin.PluginInvocationInfoShape + Description: Invocation-start and invocation-end hook info carries the full invocation field set + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + + PluginOperationInfoShape: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["10-20"] + Properties: + CodeUri: . + Handler: plugin.PluginOperationInfoShape + Description: Operation-start and operation-end hook info carries the full operation field set + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + + PluginAttemptInfoShape: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["10-21"] + Properties: + CodeUri: . + Handler: plugin.PluginAttemptInfoShape + Description: Attempt-start and attempt-end hook info carries the full attempt field set + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300 + + PluginOperationChangeShape: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["10-22"] + Properties: + CodeUri: . + Handler: plugin.PluginOperationChangeShape + Description: Operation-change hook info carries full operation items in the delta and full map + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300