|
| 1 | +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | +package software.amazon.lambda.durable; |
| 4 | + |
| 5 | +import static org.junit.jupiter.api.Assertions.assertEquals; |
| 6 | +import static org.junit.jupiter.api.Assertions.assertNotNull; |
| 7 | + |
| 8 | +import java.time.Duration; |
| 9 | +import java.util.ArrayList; |
| 10 | +import java.util.Collections; |
| 11 | +import java.util.List; |
| 12 | +import java.util.function.Consumer; |
| 13 | +import java.util.function.Function; |
| 14 | +import org.junit.jupiter.params.ParameterizedTest; |
| 15 | +import org.junit.jupiter.params.provider.EnumSource; |
| 16 | +import software.amazon.awssdk.services.lambda.model.ErrorObject; |
| 17 | +import software.amazon.lambda.durable.config.StepConfig; |
| 18 | +import software.amazon.lambda.durable.exception.CallbackFailedException; |
| 19 | +import software.amazon.lambda.durable.exception.CallbackTimeoutException; |
| 20 | +import software.amazon.lambda.durable.exception.ChildContextFailedException; |
| 21 | +import software.amazon.lambda.durable.exception.InvokeStoppedException; |
| 22 | +import software.amazon.lambda.durable.exception.InvokeTimedOutException; |
| 23 | +import software.amazon.lambda.durable.execution.SuspendExecutionException; |
| 24 | +import software.amazon.lambda.durable.model.ExecutionStatus; |
| 25 | +import software.amazon.lambda.durable.retry.RetryStrategies; |
| 26 | +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; |
| 27 | +import software.amazon.lambda.durable.testing.TestResult; |
| 28 | + |
| 29 | +/** |
| 30 | + * A failed context checkpoints the wrapped (original) error, never the SDK wrapper, and the live and replayed failure |
| 31 | + * that user code catches has the same type and error object. |
| 32 | + */ |
| 33 | +class ChildContextWrappedErrorIntegrationTest { |
| 34 | + |
| 35 | + static final String MSG = "card declined"; |
| 36 | + |
| 37 | + public static class PaymentError extends RuntimeException { |
| 38 | + public PaymentError(String message) { |
| 39 | + super(message); |
| 40 | + } |
| 41 | + } |
| 42 | + |
| 43 | + private static final StepConfig NO_RETRY = |
| 44 | + StepConfig.builder().retryStrategy(RetryStrategies.Presets.NO_RETRY).build(); |
| 45 | + |
| 46 | + /** What user code caught: the exception class and the type and message of its error object. */ |
| 47 | + private record Caught(String exception, String errorType, String errorMessage) {} |
| 48 | + |
| 49 | + private static Caught caught(Throwable t) { |
| 50 | + var eo = t instanceof software.amazon.lambda.durable.exception.DurableOperationException d |
| 51 | + ? d.getErrorObject() |
| 52 | + : null; |
| 53 | + return new Caught( |
| 54 | + t.getClass().getName(), |
| 55 | + eo == null ? null : eo.errorType(), |
| 56 | + eo == null ? t.getMessage() : eo.errorMessage()); |
| 57 | + } |
| 58 | + |
| 59 | + private record Outcome(Caught live, Caught replayed, TestResult<String> result) {} |
| 60 | + |
| 61 | + /** |
| 62 | + * Runs {@code failing}, records what it threw, waits so that a new invocation starts, and records what the replay |
| 63 | + * of {@code failing} throws. |
| 64 | + */ |
| 65 | + private static Outcome run( |
| 66 | + Function<DurableContext, Object> failing, Consumer<LocalDurableTestRunner<String, String>> drive) { |
| 67 | + List<Caught> seen = Collections.synchronizedList(new ArrayList<>()); |
| 68 | + var runner = LocalDurableTestRunner.create(String.class, (String in, DurableContext ctx) -> { |
| 69 | + try { |
| 70 | + failing.apply(ctx); |
| 71 | + throw new IllegalStateException("did not fail"); |
| 72 | + } catch (SuspendExecutionException e) { |
| 73 | + throw e; |
| 74 | + } catch (RuntimeException e) { |
| 75 | + seen.add(caught(e)); |
| 76 | + } |
| 77 | + ctx.wait("pause", Duration.ofSeconds(1)); |
| 78 | + return "done"; |
| 79 | + }); |
| 80 | + if (drive != null) { |
| 81 | + runner.run("in"); |
| 82 | + drive.accept(runner); |
| 83 | + } |
| 84 | + var result = runner.runUntilComplete("in"); |
| 85 | + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); |
| 86 | + assertEquals(2, seen.size(), "one live and one replayed failure: " + seen); |
| 87 | + return new Outcome(seen.get(0), seen.get(1), result); |
| 88 | + } |
| 89 | + |
| 90 | + private static ErrorObject contextError(TestResult<String> result, String name) { |
| 91 | + var details = result.getOperation(name).getContextDetails(); |
| 92 | + assertNotNull(details, name + " context details"); |
| 93 | + return details.error(); |
| 94 | + } |
| 95 | + |
| 96 | + private static ErrorObject error(String type) { |
| 97 | + return ErrorObject.builder().errorType(type).errorMessage(MSG).build(); |
| 98 | + } |
| 99 | + |
| 100 | + private static Object invokeInChild(DurableContext ctx) { |
| 101 | + return ctx.runInChildContext("child", String.class, cc -> cc.invoke("callee", "fn", "x", String.class)); |
| 102 | + } |
| 103 | + |
| 104 | + private static Object callbackInChild(DurableContext ctx) { |
| 105 | + return ctx.runInChildContext( |
| 106 | + "child", |
| 107 | + String.class, |
| 108 | + cc -> cc.createCallback("cb", String.class).get()); |
| 109 | + } |
| 110 | + |
| 111 | + enum Scenario { |
| 112 | + STEP_ERROR, |
| 113 | + INVOKE_FAILED, |
| 114 | + INVOKE_STOPPED, |
| 115 | + INVOKE_STOPPED_USER_TYPE, |
| 116 | + INVOKE_TIMED_OUT, |
| 117 | + CALLBACK_FAILED, |
| 118 | + CALLBACK_FAILED_NO_TYPE, |
| 119 | + CALLBACK_FAILED_TYPE_ERROR, |
| 120 | + CALLBACK_TIMED_OUT |
| 121 | + } |
| 122 | + |
| 123 | + @ParameterizedTest |
| 124 | + @EnumSource(Scenario.class) |
| 125 | + void contextCheckpointsTheWrappedErrorAndReplaysTheSameShape(Scenario scenario) { |
| 126 | + Outcome outcome; |
| 127 | + String type; |
| 128 | + String message = MSG; |
| 129 | + switch (scenario) { |
| 130 | + case STEP_ERROR -> { |
| 131 | + outcome = run( |
| 132 | + c -> c.runInChildContext( |
| 133 | + "child", |
| 134 | + String.class, |
| 135 | + cc -> cc.step( |
| 136 | + "charge", |
| 137 | + String.class, |
| 138 | + sc -> { |
| 139 | + throw new PaymentError(MSG); |
| 140 | + }, |
| 141 | + NO_RETRY)), |
| 142 | + null); |
| 143 | + type = PaymentError.class.getName(); |
| 144 | + } |
| 145 | + case INVOKE_FAILED -> { |
| 146 | + outcome = run( |
| 147 | + ChildContextWrappedErrorIntegrationTest::invokeInChild, |
| 148 | + r -> r.failChainedInvoke("callee", error("com.acme.PaymentError"))); |
| 149 | + type = "com.acme.PaymentError"; |
| 150 | + } |
| 151 | + case INVOKE_STOPPED -> { |
| 152 | + outcome = run( |
| 153 | + ChildContextWrappedErrorIntegrationTest::invokeInChild, |
| 154 | + r -> r.stopChainedInvoke("callee", error("States.Stopped"))); |
| 155 | + type = InvokeStoppedException.class.getName(); |
| 156 | + } |
| 157 | + case INVOKE_STOPPED_USER_TYPE -> { |
| 158 | + outcome = run( |
| 159 | + ChildContextWrappedErrorIntegrationTest::invokeInChild, |
| 160 | + r -> r.stopChainedInvoke("callee", error("com.acme.PaymentError"))); |
| 161 | + type = InvokeStoppedException.class.getName(); |
| 162 | + } |
| 163 | + case INVOKE_TIMED_OUT -> { |
| 164 | + outcome = run( |
| 165 | + ChildContextWrappedErrorIntegrationTest::invokeInChild, r -> r.timeoutChainedInvoke("callee")); |
| 166 | + type = InvokeTimedOutException.class.getName(); |
| 167 | + message = null; |
| 168 | + } |
| 169 | + case CALLBACK_FAILED -> { |
| 170 | + outcome = run( |
| 171 | + ChildContextWrappedErrorIntegrationTest::callbackInChild, |
| 172 | + r -> r.failCallback(r.getCallbackId("cb"), error("com.acme.PaymentError"))); |
| 173 | + type = "com.acme.PaymentError"; |
| 174 | + } |
| 175 | + case CALLBACK_FAILED_NO_TYPE -> { |
| 176 | + outcome = run( |
| 177 | + ChildContextWrappedErrorIntegrationTest::callbackInChild, |
| 178 | + r -> r.failCallback(r.getCallbackId("cb"), error(null))); |
| 179 | + type = CallbackFailedException.class.getName(); |
| 180 | + } |
| 181 | + case CALLBACK_FAILED_TYPE_ERROR -> { |
| 182 | + // The explicit type "Error" is a type: it is kept, unlike a missing one. |
| 183 | + outcome = run( |
| 184 | + ChildContextWrappedErrorIntegrationTest::callbackInChild, |
| 185 | + r -> r.failCallback(r.getCallbackId("cb"), error("Error"))); |
| 186 | + type = "Error"; |
| 187 | + } |
| 188 | + case CALLBACK_TIMED_OUT -> { |
| 189 | + outcome = run( |
| 190 | + ChildContextWrappedErrorIntegrationTest::callbackInChild, |
| 191 | + r -> r.timeoutCallback(r.getCallbackId("cb"))); |
| 192 | + type = CallbackTimeoutException.class.getName(); |
| 193 | + message = null; // SDK message with the callback id, checked below |
| 194 | + } |
| 195 | + default -> throw new IllegalArgumentException(scenario.name()); |
| 196 | + } |
| 197 | + |
| 198 | + var checkpoint = contextError(outcome.result(), "child"); |
| 199 | + assertNotNull(checkpoint, "context checkpoint error"); |
| 200 | + assertEquals(type, checkpoint.errorType(), "context checkpoint errorType"); |
| 201 | + if (scenario == Scenario.CALLBACK_TIMED_OUT) { |
| 202 | + assertEquals( |
| 203 | + "Callback timed out: " |
| 204 | + + outcome.result() |
| 205 | + .getOperation("cb") |
| 206 | + .getCallbackDetails() |
| 207 | + .callbackId(), |
| 208 | + checkpoint.errorMessage()); |
| 209 | + } else { |
| 210 | + assertEquals(message, checkpoint.errorMessage(), "context checkpoint errorMessage"); |
| 211 | + } |
| 212 | + |
| 213 | + assertEquals(outcome.live(), outcome.replayed(), "live and replayed failures have the same shape"); |
| 214 | + if (scenario == Scenario.STEP_ERROR) { |
| 215 | + assertEquals(PaymentError.class.getName(), outcome.live().exception()); |
| 216 | + } else { |
| 217 | + assertEquals( |
| 218 | + ChildContextFailedException.class.getName(), outcome.live().exception()); |
| 219 | + assertEquals(type, outcome.live().errorType()); |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + @ParameterizedTest |
| 224 | + @EnumSource( |
| 225 | + value = Scenario.class, |
| 226 | + names = {"INVOKE_STOPPED", "INVOKE_TIMED_OUT"}) |
| 227 | + void nestedContextsKeepTheSdkErrorIdentity(Scenario scenario) { |
| 228 | + var outcome = run( |
| 229 | + c -> c.runInChildContext( |
| 230 | + "outer", String.class, oc -> invokeInChild(oc).toString()), |
| 231 | + r -> { |
| 232 | + if (scenario == Scenario.INVOKE_STOPPED) { |
| 233 | + r.stopChainedInvoke("callee", error("States.Stopped")); |
| 234 | + } else { |
| 235 | + r.timeoutChainedInvoke("callee"); |
| 236 | + } |
| 237 | + }); |
| 238 | + String type = scenario == Scenario.INVOKE_STOPPED |
| 239 | + ? InvokeStoppedException.class.getName() |
| 240 | + : InvokeTimedOutException.class.getName(); |
| 241 | + assertEquals(type, contextError(outcome.result(), "child").errorType(), "inner context"); |
| 242 | + assertEquals(type, contextError(outcome.result(), "outer").errorType(), "outer context"); |
| 243 | + assertEquals(outcome.live(), outcome.replayed()); |
| 244 | + assertEquals(type, outcome.live().errorType()); |
| 245 | + } |
| 246 | + |
| 247 | + @ParameterizedTest |
| 248 | + @EnumSource( |
| 249 | + value = Scenario.class, |
| 250 | + names = {"INVOKE_STOPPED", "CALLBACK_TIMED_OUT"}) |
| 251 | + void mapItemCheckpointCarriesTheSdkErrorType(Scenario scenario) { |
| 252 | + var runner = LocalDurableTestRunner.create(String.class, (String in, DurableContext ctx) -> { |
| 253 | + var result = ctx.map("fanout", List.of("a"), String.class, (item, i, mc) -> { |
| 254 | + if (scenario == Scenario.INVOKE_STOPPED) { |
| 255 | + return mc.invoke("callee", "fn", "x", String.class); |
| 256 | + } |
| 257 | + return mc.createCallback("cb", String.class).get(); |
| 258 | + }); |
| 259 | + return result.getError(0) == null ? "no error" : "failed"; |
| 260 | + }); |
| 261 | + runner.run("in"); |
| 262 | + if (scenario == Scenario.INVOKE_STOPPED) { |
| 263 | + runner.stopChainedInvoke("callee", error("States.Stopped")); |
| 264 | + } else { |
| 265 | + runner.timeoutCallback(runner.getCallbackId("cb")); |
| 266 | + } |
| 267 | + var result = runner.runUntilComplete("in"); |
| 268 | + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); |
| 269 | + String type = scenario == Scenario.INVOKE_STOPPED |
| 270 | + ? InvokeStoppedException.class.getName() |
| 271 | + : CallbackTimeoutException.class.getName(); |
| 272 | + assertEquals("failed", result.getResult(String.class)); |
| 273 | + assertEquals(type, contextError(result, "fanout-iteration-0").errorType()); |
| 274 | + } |
| 275 | + |
| 276 | + /** |
| 277 | + * {@code waitForCallback} rethrows the callback's own exception live, so its context keeps the callback's own error |
| 278 | + * object: a replay that can read only the completed context then reports the same error object. |
| 279 | + */ |
| 280 | + @ParameterizedTest |
| 281 | + @EnumSource( |
| 282 | + value = Scenario.class, |
| 283 | + names = {"CALLBACK_FAILED", "CALLBACK_FAILED_NO_TYPE", "CALLBACK_TIMED_OUT"}) |
| 284 | + void waitForCallbackContextKeepsTheCallbackErrorObject(Scenario scenario) { |
| 285 | + var outcome = run(c -> c.waitForCallback("wfc", String.class, (id, sc) -> {}), r -> { |
| 286 | + switch (scenario) { |
| 287 | + case CALLBACK_TIMED_OUT -> r.timeoutCallback(r.getCallbackId("wfc-callback")); |
| 288 | + case CALLBACK_FAILED -> r.failCallback(r.getCallbackId("wfc-callback"), error("com.acme.PaymentError")); |
| 289 | + default -> r.failCallback(r.getCallbackId("wfc-callback"), error(null)); |
| 290 | + } |
| 291 | + }); |
| 292 | + var callbackError = outcome.result() |
| 293 | + .getOperation("wfc-callback") |
| 294 | + .getCallbackDetails() |
| 295 | + .error(); |
| 296 | + assertEquals(callbackError, contextError(outcome.result(), "wfc")); |
| 297 | + assertEquals(outcome.live().errorType(), outcome.replayed().errorType()); |
| 298 | + String live = scenario == Scenario.CALLBACK_TIMED_OUT |
| 299 | + ? CallbackTimeoutException.class.getName() |
| 300 | + : CallbackFailedException.class.getName(); |
| 301 | + assertEquals(live, outcome.live().exception()); |
| 302 | + } |
| 303 | +} |
0 commit comments