From 09bd10066b9299043e767e41eac0fa9a84a22fac Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Thu, 6 Aug 2026 21:16:21 +0000 Subject: [PATCH 1/4] Add hook-info field-shape handlers for 10-19..10-22 --- .../java/plugin/PluginAttemptInfoShape.java | 102 ++++++++++++++++++ .../plugin/PluginInvocationInfoShape.java | 81 ++++++++++++++ .../plugin/PluginOperationChangeShape.java | 80 ++++++++++++++ .../java/plugin/PluginOperationInfoShape.java | 89 +++++++++++++++ conformance-tests/template_plugin.yaml | 64 +++++++++++ 5 files changed, 416 insertions(+) create mode 100644 conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java create mode 100644 conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java create mode 100644 conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java create mode 100644 conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java 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..646b79d86 --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java @@ -0,0 +1,102 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package plugin; + +import java.time.Duration; +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. + * + *

A single step named {@code "flaky"} that throws on its first attempt and succeeds on the second, driven by the + * SDK's built-in {@code getAttempt()} and a real exponential-backoff retry strategy (max attempts 3, ~1s delay), + * returning {@code "ok"}. INTERFACE-SHAPE probe of the per-attempt (user-function) hooks, filtered to step-type + * operations. Every logged field is read from the CURRENT hook's own info parameter only. Java's + * {@link UserFunctionStartInfo} carries identity, {@code startTimestamp}, and the 1-based {@code attempt}; + * {@link UserFunctionEndInfo} carries the {@code succeeded} boolean (presented as the {@code outcome} token — a + * presentation of the API's own data, not a reconstruction) and {@code error}. Replay indicators are emitted for + * observability but not asserted. + */ +@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; + } + boolean hasStartTime = info.startTimestamp() != null; + System.out.println(String.format( + "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"attempt-start\", \"op\": \"%s\", \"name\": \"%s\", " + + "\"type\": \"%s\", \"attempt\": %d, \"has_start_time\": %b%s}", + info.id(), + info.name(), + info.type().toUpperCase(Locale.ROOT), + info.attempt(), + hasStartTime, + PluginSupport.arnField(executionArn))); + } + + @Override + public void onUserFunctionEnd(UserFunctionEndInfo info) { + if (!PluginSupport.isStep(info.type()) || info.attempt() == null) { + return; + } + // outcome presents the info's own succeeded boolean; has_error reflects the attempt's error object. + String outcome = info.succeeded() ? "SUCCEEDED" : "FAILED"; + boolean hasError = info.error() != null; + System.out.println(String.format( + "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"attempt-end\", \"op\": \"%s\", \"name\": \"%s\", " + + "\"type\": \"%s\", \"attempt\": %d, \"outcome\": \"%s\", \"has_error\": %b%s}", + info.id(), + info.name(), + info.type().toUpperCase(Locale.ROOT), + info.attempt(), + outcome, + hasError, + PluginSupport.arnField(executionArn))); + } + } +} 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..158e40346 --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package plugin; + +import java.time.Duration; +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. + * + *

A single 2-second wait that then returns {@code "done-" + input}. INTERFACE-SHAPE probe: every logged field is + * read from the CURRENT hook's own info parameter only — never reconstructed from another hook or from plugin state. + * Java's {@link InvocationInfo} exposes {@code requestId} and {@code executionStartTime} but does NOT expose the + * execution input, the execution operations map, or an externally-updated-operations collection; those are honestly + * emitted as {@code has_*: false} with the value key omitted. Likewise {@link InvocationEndInfo} exposes + * {@code invocationStatus} and {@code executionError} but not the execution's final result, so {@code has_result} is + * honestly false. Those omissions are the parity signals the requirement exists to produce. + */ +@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(); + boolean hasRequestId = info.requestId() != null; + boolean hasInput = false; // no execution-input accessor on InvocationInfo + boolean hasOperations = false; // no operations map on InvocationInfo + boolean updatedNonempty = false; // no externally-updated-operations collection on InvocationInfo + boolean hasStartTime = info.executionStartTime() != null; + System.out.println(String.format( + "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"invocation-start\", \"first\": %b, " + + "\"has_request_id\": %b, \"has_input\": %b, \"has_operations\": %b, " + + "\"updated_nonempty\": %b, \"has_start_time\": %b%s}", + info.isFirstInvocation(), + hasRequestId, + hasInput, + hasOperations, + updatedNonempty, + hasStartTime, + PluginSupport.arnField(executionArn))); + } + + @Override + public void onInvocationEnd(InvocationEndInfo info) { + InvocationStatus status = info.invocationStatus(); + // terminal := status in (SUCCEEDED, FAILED); first is read from the END info parameter itself. + boolean terminal = status == InvocationStatus.SUCCEEDED || status == InvocationStatus.FAILED; + boolean hasResult = false; // no final-result accessor on InvocationEndInfo + boolean hasError = info.executionError() != null; + System.out.println(String.format( + "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"invocation-end\", \"first\": %b, \"terminal\": %b, " + + "\"status\": \"%s\", \"has_result\": %b, \"has_error\": %b%s}", + info.isFirstInvocation(), + terminal, + status.name(), + hasResult, + hasError, + PluginSupport.arnField(executionArn))); + } + } +} 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..e9e0cb191 --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package plugin; + +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. + * + *

A single step named {@code "greet"} returning the constant {@code "task-a"}. INTERFACE-SHAPE probe of the + * operation-change hook: every logged field is read from the CURRENT hook's own info parameter only. For each step-type + * operation in the change info's updated-operations delta the plugin reports the operation id, its post-change status, + * whether the same id is present in the info's full operations map, whether the change info itself carries the + * execution ARN, and the DELTA ITEM's own field surface. Java's {@link OperationChangeItemInfo} carries identity, + * {@code startTimestamp}, {@code endTimestamp}, {@code error}, and {@code status}, but does NOT expose the checkpointed + * serialized result, the attempt number, or a replay indicator — so {@code item_has_result}, {@code item_has_attempt}, + * and {@code item_has_replay} are honestly false. Those omissions are the parity signals the requirement exists to + * produce. + */ +@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) { + for (OperationChangeItemInfo item : info.updatedOperations().values()) { + if (!PluginSupport.isStepChange(item.type())) { + continue; + } + boolean inFullMap = info.operations().containsKey(item.id()); + boolean hasArn = info.durableExecutionArn() != null; + String status = item.status() != null ? item.status().toString() : "NONE"; + boolean itemHasResult = false; // no serialized-result accessor on OperationChangeItemInfo + boolean itemHasEndTime = item.endTimestamp() != null; + boolean itemHasAttempt = false; // no attempt accessor on OperationChangeItemInfo + boolean itemHasReplay = false; // no replay-indicator accessor on OperationChangeItemInfo + System.out.println(String.format( + "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"operation-change\", \"op\": \"%s\", " + + "\"status\": \"%s\", \"in_full_map\": %b, \"has_arn\": %b, \"item_name\": \"%s\", " + + "\"item_type\": \"%s\", \"item_has_result\": %b, \"item_has_end_time\": %b, " + + "\"item_has_attempt\": %b, \"item_has_replay\": %b%s}", + item.id(), + status, + inFullMap, + hasArn, + item.name(), + item.type().toUpperCase(Locale.ROOT), + itemHasResult, + itemHasEndTime, + itemHasAttempt, + itemHasReplay, + PluginSupport.arnField(executionArn))); + } + } + } +} 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..3de67f47b --- /dev/null +++ b/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package plugin; + +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. + * + *

A single step named {@code "greet"} returning the constant {@code "task-a"}. INTERFACE-SHAPE probe filtering to + * step-type operations: every logged field is read from the CURRENT hook's own info parameter only. Java's + * {@link OperationInfo} carries identity, {@code startTimestamp}, {@code status}, and {@code isReplay}; at + * operation-start {@code has_status} is emitted for observability but not asserted. Java's {@link OperationEndInfo} + * carries {@code status}, {@code attempt}, {@code endTimestamp}, and {@code error} but does NOT expose the operation's + * checkpointed serialized result, so {@code has_result} is honestly false and the {@code result} value key is omitted — + * that omission is the parity signal the requirement exists to produce. + */ +@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; + } + boolean hasStartTime = info.startTimestamp() != null; + boolean hasStatus = info.status() != null; // emitted for observability, not asserted at start + System.out.println(String.format( + "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"operation-start\", \"op\": \"%s\", \"name\": \"%s\", " + + "\"type\": \"%s\", \"replay\": %b, \"has_start_time\": %b, \"has_status\": %b%s}", + info.id(), + info.name(), + info.type().toUpperCase(Locale.ROOT), + info.isReplay(), + hasStartTime, + hasStatus, + PluginSupport.arnField(executionArn))); + } + + @Override + public void onOperationEnd(OperationEndInfo info) { + if (!PluginSupport.isStep(info.type())) { + return; + } + boolean hasResult = false; // no checkpointed-result accessor on OperationEndInfo; result key omitted + boolean hasError = info.error() != null; + boolean hasEndTime = info.endTimestamp() != null; + System.out.println(String.format( + "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"operation-end\", \"op\": \"%s\", \"name\": \"%s\", " + + "\"type\": \"%s\", \"replay\": %b, \"status\": \"%s\", \"has_result\": %b, " + + "\"has_error\": %b, \"attempt\": %s, \"has_end_time\": %b%s}", + info.id(), + info.name(), + info.type().toUpperCase(Locale.ROOT), + info.isReplay(), + info.status(), + hasResult, + hasError, + info.attempt(), + hasEndTime, + PluginSupport.arnField(executionArn))); + } + } +} 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 From c3b9c3b18ca389bad24a5096bf31869bc204ff2f Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Thu, 6 Aug 2026 22:49:55 +0000 Subject: [PATCH 2/4] Convert shape probes to canonical dump records --- .../java/plugin/PluginAttemptInfoShape.java | 156 +++++++++++++---- .../plugin/PluginInvocationInfoShape.java | 140 +++++++++++---- .../plugin/PluginOperationChangeShape.java | 155 +++++++++++++---- .../java/plugin/PluginOperationInfoShape.java | 161 ++++++++++++++---- 4 files changed, 474 insertions(+), 138 deletions(-) diff --git a/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java index 646b79d86..d6cfc7aef 100644 --- a/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java +++ b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java @@ -3,6 +3,7 @@ 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; @@ -16,16 +17,18 @@ import software.amazon.lambda.durable.retry.RetryStrategies; /** - * 10-21: Attempt hook info field shape. + * 10-21: Attempt hook info field shape (CANONICAL DUMP). * - *

A single step named {@code "flaky"} that throws on its first attempt and succeeds on the second, driven by the - * SDK's built-in {@code getAttempt()} and a real exponential-backoff retry strategy (max attempts 3, ~1s delay), - * returning {@code "ok"}. INTERFACE-SHAPE probe of the per-attempt (user-function) hooks, filtered to step-type - * operations. Every logged field is read from the CURRENT hook's own info parameter only. Java's - * {@link UserFunctionStartInfo} carries identity, {@code startTimestamp}, and the 1-based {@code attempt}; - * {@link UserFunctionEndInfo} carries the {@code succeeded} boolean (presented as the {@code outcome} token — a - * presentation of the API's own data, not a reconstruction) and {@code error}. Replay indicators are emitted for - * observability but not asserted. + *

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 { @@ -67,16 +70,16 @@ public void onUserFunctionStart(UserFunctionStartInfo info) { if (!PluginSupport.isStep(info.type()) || info.attempt() == null) { return; } - boolean hasStartTime = info.startTimestamp() != null; - System.out.println(String.format( - "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"attempt-start\", \"op\": \"%s\", \"name\": \"%s\", " - + "\"type\": \"%s\", \"attempt\": %d, \"has_start_time\": %b%s}", - info.id(), - info.name(), - info.type().toUpperCase(Locale.ROOT), - info.attempt(), - hasStartTime, - PluginSupport.arnField(executionArn))); + 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 @@ -84,19 +87,108 @@ public void onUserFunctionEnd(UserFunctionEndInfo info) { if (!PluginSupport.isStep(info.type()) || info.attempt() == null) { return; } - // outcome presents the info's own succeeded boolean; has_error reflects the attempt's error object. - String outcome = info.succeeded() ? "SUCCEEDED" : "FAILED"; - boolean hasError = info.error() != null; - System.out.println(String.format( - "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"attempt-end\", \"op\": \"%s\", \"name\": \"%s\", " - + "\"type\": \"%s\", \"attempt\": %d, \"outcome\": \"%s\", \"has_error\": %b%s}", - info.id(), - info.name(), - info.type().toUpperCase(Locale.ROOT), - info.attempt(), - outcome, - hasError, - PluginSupport.arnField(executionArn))); + 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 index 158e40346..c306c80da 100644 --- a/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java +++ b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java @@ -3,6 +3,7 @@ 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; @@ -12,15 +13,20 @@ import software.amazon.lambda.durable.plugin.InvocationStatus; /** - * 10-19: Invocation hook info field shape. + * 10-19: Invocation hook info field shape (CANONICAL DUMP). * - *

A single 2-second wait that then returns {@code "done-" + input}. INTERFACE-SHAPE probe: every logged field is - * read from the CURRENT hook's own info parameter only — never reconstructed from another hook or from plugin state. - * Java's {@link InvocationInfo} exposes {@code requestId} and {@code executionStartTime} but does NOT expose the - * execution input, the execution operations map, or an externally-updated-operations collection; those are honestly - * emitted as {@code has_*: false} with the value key omitted. Likewise {@link InvocationEndInfo} exposes - * {@code invocationStatus} and {@code executionError} but not the execution's final result, so {@code has_result} is - * honestly false. Those omissions are the parity signals the requirement exists to produce. + *

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 { @@ -42,40 +48,102 @@ private static final class InvocationShapePlugin implements DurableExecutionPlug @Override public void onInvocationStart(InvocationInfo info) { this.executionArn = info.durableExecutionArn(); - boolean hasRequestId = info.requestId() != null; - boolean hasInput = false; // no execution-input accessor on InvocationInfo - boolean hasOperations = false; // no operations map on InvocationInfo - boolean updatedNonempty = false; // no externally-updated-operations collection on InvocationInfo - boolean hasStartTime = info.executionStartTime() != null; - System.out.println(String.format( - "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"invocation-start\", \"first\": %b, " - + "\"has_request_id\": %b, \"has_input\": %b, \"has_operations\": %b, " - + "\"updated_nonempty\": %b, \"has_start_time\": %b%s}", - info.isFirstInvocation(), - hasRequestId, - hasInput, - hasOperations, - updatedNonempty, - hasStartTime, - PluginSupport.arnField(executionArn))); + 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(); - // terminal := status in (SUCCEEDED, FAILED); first is read from the END info parameter itself. boolean terminal = status == InvocationStatus.SUCCEEDED || status == InvocationStatus.FAILED; - boolean hasResult = false; // no final-result accessor on InvocationEndInfo - boolean hasError = info.executionError() != null; - System.out.println(String.format( - "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"invocation-end\", \"first\": %b, \"terminal\": %b, " - + "\"status\": \"%s\", \"has_result\": %b, \"has_error\": %b%s}", - info.isFirstInvocation(), - terminal, - status.name(), - hasResult, - hasError, - PluginSupport.arnField(executionArn))); + 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 index e9e0cb191..7d73a6093 100644 --- a/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java +++ b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java @@ -2,6 +2,7 @@ // 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; @@ -12,17 +13,18 @@ import software.amazon.lambda.durable.plugin.OperationChangeItemInfo; /** - * 10-22: Operation-change hook info field shape. + * 10-22: Operation-change hook info field shape (CANONICAL DUMP). * - *

A single step named {@code "greet"} returning the constant {@code "task-a"}. INTERFACE-SHAPE probe of the - * operation-change hook: every logged field is read from the CURRENT hook's own info parameter only. For each step-type - * operation in the change info's updated-operations delta the plugin reports the operation id, its post-change status, - * whether the same id is present in the info's full operations map, whether the change info itself carries the - * execution ARN, and the DELTA ITEM's own field surface. Java's {@link OperationChangeItemInfo} carries identity, - * {@code startTimestamp}, {@code endTimestamp}, {@code error}, and {@code status}, but does NOT expose the checkpointed - * serialized result, the attempt number, or a replay indicator — so {@code item_has_result}, {@code item_has_attempt}, - * and {@code item_has_replay} are honestly false. Those omissions are the parity signals the requirement exists to - * produce. + *

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 { @@ -47,34 +49,121 @@ public void onInvocationStart(InvocationInfo info) { @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; } - boolean inFullMap = info.operations().containsKey(item.id()); - boolean hasArn = info.durableExecutionArn() != null; - String status = item.status() != null ? item.status().toString() : "NONE"; - boolean itemHasResult = false; // no serialized-result accessor on OperationChangeItemInfo - boolean itemHasEndTime = item.endTimestamp() != null; - boolean itemHasAttempt = false; // no attempt accessor on OperationChangeItemInfo - boolean itemHasReplay = false; // no replay-indicator accessor on OperationChangeItemInfo - System.out.println(String.format( - "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"operation-change\", \"op\": \"%s\", " - + "\"status\": \"%s\", \"in_full_map\": %b, \"has_arn\": %b, \"item_name\": \"%s\", " - + "\"item_type\": \"%s\", \"item_has_result\": %b, \"item_has_end_time\": %b, " - + "\"item_has_attempt\": %b, \"item_has_replay\": %b%s}", - item.id(), - status, - inFullMap, - hasArn, - item.name(), - item.type().toUpperCase(Locale.ROOT), - itemHasResult, - itemHasEndTime, - itemHasAttempt, - itemHasReplay, - PluginSupport.arnField(executionArn))); + 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 index 3de67f47b..71bfce0c0 100644 --- a/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java +++ b/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java @@ -2,6 +2,7 @@ // 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; @@ -12,15 +13,17 @@ import software.amazon.lambda.durable.plugin.OperationInfo; /** - * 10-20: Operation hook info field shape. + * 10-20: Operation hook info field shape (CANONICAL DUMP). * - *

A single step named {@code "greet"} returning the constant {@code "task-a"}. INTERFACE-SHAPE probe filtering to - * step-type operations: every logged field is read from the CURRENT hook's own info parameter only. Java's - * {@link OperationInfo} carries identity, {@code startTimestamp}, {@code status}, and {@code isReplay}; at - * operation-start {@code has_status} is emitted for observability but not asserted. Java's {@link OperationEndInfo} - * carries {@code status}, {@code attempt}, {@code endTimestamp}, and {@code error} but does NOT expose the operation's - * checkpointed serialized result, so {@code has_result} is honestly false and the {@code result} value key is omitted — - * that omission is the parity signal the requirement exists to produce. + *

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 { @@ -48,18 +51,17 @@ public void onOperationStart(OperationInfo info) { if (!PluginSupport.isStep(info.type())) { return; } - boolean hasStartTime = info.startTimestamp() != null; - boolean hasStatus = info.status() != null; // emitted for observability, not asserted at start - System.out.println(String.format( - "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"operation-start\", \"op\": \"%s\", \"name\": \"%s\", " - + "\"type\": \"%s\", \"replay\": %b, \"has_start_time\": %b, \"has_status\": %b%s}", - info.id(), - info.name(), - info.type().toUpperCase(Locale.ROOT), - info.isReplay(), - hasStartTime, - hasStatus, - PluginSupport.arnField(executionArn))); + 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 @@ -67,23 +69,108 @@ public void onOperationEnd(OperationEndInfo info) { if (!PluginSupport.isStep(info.type())) { return; } - boolean hasResult = false; // no checkpointed-result accessor on OperationEndInfo; result key omitted - boolean hasError = info.error() != null; - boolean hasEndTime = info.endTimestamp() != null; - System.out.println(String.format( - "{\"plugin\": \"CONFPLUGIN\", \"hook\": \"operation-end\", \"op\": \"%s\", \"name\": \"%s\", " - + "\"type\": \"%s\", \"replay\": %b, \"status\": \"%s\", \"has_result\": %b, " - + "\"has_error\": %b, \"attempt\": %s, \"has_end_time\": %b%s}", - info.id(), - info.name(), - info.type().toUpperCase(Locale.ROOT), - info.isReplay(), - info.status(), - hasResult, - hasError, - info.attempt(), - hasEndTime, - PluginSupport.arnField(executionArn))); + 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(); } } } From 09f0e60c64dd90f64ffaebcb5376e65f953151d3 Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Fri, 7 Aug 2026 21:04:29 +0000 Subject: [PATCH 3/4] Add context-typed shape handler for 10-23 --- .../java/plugin/PluginContextInfoShape.java | 187 ++++++++++++++++++ .../src/main/java/plugin/PluginSupport.java | 10 + conformance-tests/template_plugin.yaml | 16 ++ 3 files changed, 213 insertions(+) create mode 100644 conformance-tests/src/main/java/plugin/PluginContextInfoShape.java 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/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 ebc680135..4de478f31 100644 --- a/conformance-tests/template_plugin.yaml +++ b/conformance-tests/template_plugin.yaml @@ -368,3 +368,19 @@ Resources: 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 From 2b28744f3e7697db95bbc534c4dd625706c9307b Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Mon, 10 Aug 2026 23:39:08 +0000 Subject: [PATCH 4/4] test(conformance): dump the newly exposed hook info fields The parity fix beneath this commit exposes fields the shape handlers could not previously read. Dump them so 10-19, 10-21 and 10-22 assert the real surface rather than recording its absence: - 10-19: operationsCount and updatedOperationsCount on invocation-start, operationsCount and executionStartTimestamp on invocation-end. - 10-21: isReplay on attempt-start and attempt-end, alongside the distinct isReplayingChildren (dumped unasserted). - 10-22: attempt and isReplay on each change delta item. The handler javadocs previously documented these as missing Java surfaces; that prose is updated to describe what is now exposed and to note that payload fields remain deliberately absent. --- .../java/plugin/PluginAttemptInfoShape.java | 12 ++++++---- .../plugin/PluginInvocationInfoShape.java | 22 ++++++++++++++----- .../plugin/PluginOperationChangeShape.java | 9 ++++---- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java index d6cfc7aef..89d4ad3fe 100644 --- a/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java +++ b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java @@ -25,10 +25,12 @@ * 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. + *

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 { @@ -78,6 +80,7 @@ public void onUserFunctionStart(UserFunctionStartInfo info) { .str("parentId", info.parentId()) .num("attempt", info.attempt()) .time("startTimestamp", info.startTimestamp()) + .bool("isReplay", info.isReplay()) .bool("isReplayingChildren", info.isReplayingChildren()) .emit(executionArn); } @@ -96,6 +99,7 @@ public void onUserFunctionEnd(UserFunctionEndInfo info) { .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())) diff --git a/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java index c306c80da..89b41b80e 100644 --- a/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java +++ b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java @@ -20,13 +20,14 @@ * 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}, + *

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 omissions are the honest reds the requirement produces. + * {@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 { @@ -51,6 +52,8 @@ public void onInvocationStart(InvocationInfo info) { 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); } @@ -62,6 +65,8 @@ public void onInvocationEnd(InvocationEndInfo info) { 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())) @@ -85,6 +90,13 @@ Rec str(String key, String 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; diff --git a/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java index 7d73a6093..b4915114b 100644 --- a/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java +++ b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java @@ -21,10 +21,9 @@ * {@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. + *

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 { @@ -72,6 +71,8 @@ public void onOperationChange(OperationChangeInfo info) { : 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); }