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..89d4ad3fe --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java @@ -0,0 +1,198 @@ +// 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/isReplay/ + * isReplayingChildren/attempt (no endTimestamp/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}. {@code isReplay} is the operation-level replay indicator (this operation was present in the + * checkpointed state delivered at invocation start); {@code isReplayingChildren} is the distinct context-children + * indicator and is dumped unasserted here. + */ +@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("isReplay", info.isReplay()) + .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("isReplay", info.isReplay()) + .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/PluginContextInfoShape.java b/conformance-tests/src/main/java/plugin/PluginContextInfoShape.java new file mode 100644 index 000000000..966c3d934 --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginContextInfoShape.java @@ -0,0 +1,187 @@ +// 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.ArrayList; +import java.util.List; +import java.util.Locale; +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableFuture; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.ParallelDurableFuture; +import software.amazon.lambda.durable.config.ParallelConfig; +import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; +import software.amazon.lambda.durable.plugin.InvocationInfo; +import software.amazon.lambda.durable.plugin.OperationInfo; +import software.amazon.lambda.durable.plugin.UserFunctionStartInfo; + +/** + * 10-23: Context-typed hook info field shape (CANONICAL DUMP). + * + *

A parallel operation named {@code "ctx"} with max-concurrency 1 and two branches: branch A runs a step named + * {@code "inner"} returning {@code "x"}, then a 2-second wait, and returns {@code "a-done"}; branch B returns + * {@code "b-done"} directly. With max-concurrency 1 branch A runs live, suspends on the wait, and re-runs on the replay + * (replaying its checkpointed children) before branch B runs live — so the children-replay indicator flips true on + * branch A's second {@code fn-start}. + * + *

The instrumentation plugin (filtering to CONTEXT-type operations) emits ONE single-line JSON record per hook + * event: a canonical camelCase dump of that hook's OWN info parameter, null / unset fields OMITTED. + * + *

Java's {@link OperationInfo} (operation-start) exposes id/name/type/subType/parentId/startTimestamp/endTimestamp/ + * status/isReplay. Java's {@link UserFunctionStartInfo} (fn-start) exposes id/name/type/subType/parentId/ + * startTimestamp/isReplayingChildren/attempt — for CONTEXT operations {@code attempt} is null and is omitted. Only + * fn-start is probed; attempt-end hooks are out of scope for a suspending context run. + */ +@SuppressWarnings("deprecation") +public class PluginContextInfoShape extends DurableHandler> { + + @Override + protected DurableConfig createConfiguration() { + return DurableConfig.builder().withPlugins(new ContextShapePlugin()).build(); + } + + @Override + public List handleRequest(Object input, DurableContext context) { + var config = ParallelConfig.builder().maxConcurrency(1).build(); + var futures = new ArrayList>(); + ParallelDurableFuture parallel = context.parallel("ctx", config); + try (parallel) { + futures.add(parallel.branch("branch-a", String.class, branch -> { + branch.step("inner", String.class, stepCtx -> "x"); + branch.wait(null, Duration.ofSeconds(2)); + return "a-done"; + })); + futures.add(parallel.branch("branch-b", String.class, branch -> "b-done")); + } + return futures.stream().map(DurableFuture::get).toList(); + } + + private static final class ContextShapePlugin implements DurableExecutionPlugin { + private volatile String executionArn; + + @Override + public void onInvocationStart(InvocationInfo info) { + this.executionArn = info.durableExecutionArn(); + } + + @Override + public void onOperationStart(OperationInfo info) { + if (!PluginSupport.isContext(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 onUserFunctionStart(UserFunctionStartInfo info) { + if (!PluginSupport.isContext(info.type())) { + return; + } + new Rec("fn-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); + } + } + + /** 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 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..89b41b80e --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java @@ -0,0 +1,161 @@ +// 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 {@code requestId}, {@code executionStartTime} (→ + * {@code executionStartTimestamp}), {@code isFirstInvocation} and the {@code operations} / {@code updatedOperations} + * maps (dumped as {@code operationsCount} / {@code updatedOperationsCount}); it does NOT expose the execution input, so + * {@code executionInput} is absent. Java's {@link InvocationEndInfo} carries the same identity surface plus + * {@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 payload omissions are deliberate — payload surfaces are out + * of GA scope. + */ +@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()) + .num("operationsCount", info.operations().size()) + .num("updatedOperationsCount", info.updatedOperations().size()) + .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()) + .num("operationsCount", info.operations().size()) + .time("executionStartTimestamp", info.executionStartTime()) + .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 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 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..b4915114b --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java @@ -0,0 +1,170 @@ +// 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 the full operation field surface — id/name/type/subType/parentId/ + * startTimestamp/endTimestamp/status/attempt/isReplay/error. It does NOT expose the checkpointed serialized result, so + * {@code result} is absent; payload surfaces are deliberately out of GA scope. + */ +@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()) + .num("attempt", item.attempt()) + .bool("isReplay", item.isReplay()) + .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/src/main/java/plugin/PluginSupport.java b/conformance-tests/src/main/java/plugin/PluginSupport.java index 474f94e6c..5a6879e96 100644 --- a/conformance-tests/src/main/java/plugin/PluginSupport.java +++ b/conformance-tests/src/main/java/plugin/PluginSupport.java @@ -27,6 +27,16 @@ static boolean isWait(String type) { return "WAIT".equals(type); } + /** + * Operation type token for context operations (parallel, map, run-in-child-context, wait-for-callback) as reported + * by {@code OperationInfo#type()} / {@code UserFunctionStartInfo#type()} ({@code OperationType.CONTEXT}). Both the + * parallel parent and its branches report this token; the branch is distinguished by the {@code ParallelBranch} + * sub-type. + */ + static boolean isContext(String type) { + return "CONTEXT".equals(type); + } + /** * Operation type token for step operations as reported by {@code OperationChangeItemInfo#type()} * ({@code Operation#typeAsString()} straight off the checkpoint response). Compared case-insensitively because it diff --git a/conformance-tests/template_plugin.yaml b/conformance-tests/template_plugin.yaml index ecc8cac17..4de478f31 100644 --- a/conformance-tests/template_plugin.yaml +++ b/conformance-tests/template_plugin.yaml @@ -304,3 +304,83 @@ 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 + + PluginContextInfoShape: + Type: AWS::Serverless::Function + TestingMetadata: + TestDescription: ["10-23"] + Properties: + CodeUri: . + Handler: plugin.PluginContextInfoShape + Description: Context operation-start and user-function-start hook info carries subType tokens and the children-replay indicator + Role: + Fn::GetAtt: + - DurableFunctionRole + - Arn + DurableConfig: + RetentionPeriodInDays: 7 + ExecutionTimeout: 300