From 3a2fbeb91fa6ff195a2b5ee4f8e23b26f243c353 Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Thu, 6 Aug 2026 21:16:21 +0000 Subject: [PATCH 1/2] 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 443729812aee013dea7e27edaccafabce9bae973 Mon Sep 17 00:00:00 2001 From: Alex Wang Date: Thu, 6 Aug 2026 22:49:55 +0000 Subject: [PATCH 2/2] 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(); } } }