diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6f3c240 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,201 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: >- + cmake -S . -B build + -DCMAKE_BUILD_TYPE=Release + -DDURABLE_EXECUTION_BUILD_BENCHMARKS=ON + + - name: Build + run: cmake --build build --parallel 2 + + - name: Test + run: ctest --test-dir build --output-on-failure + + - name: Install + run: cmake --install build --prefix "${{ runner.temp }}/install" + + - name: Configure package consumer + run: >- + cmake -S tests/package_consumer + -B "${{ runner.temp }}/consumer" + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_PREFIX_PATH="${{ runner.temp }}/install" + + - name: Build package consumer + run: >- + cmake --build "${{ runner.temp }}/consumer" --parallel 2 + + - name: Run package consumer + run: "${{ runner.temp }}/consumer/package_consumer" + + - name: Benchmark smoke test + run: ./build/durable_execution_microbench + + sanitizers: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Configure + run: >- + cmake -S . -B build-sanitize + -DCMAKE_BUILD_TYPE=Debug + -DDURABLE_EXECUTION_BUILD_EXAMPLES=OFF + -DDURABLE_EXECUTION_ENABLE_SANITIZERS=ON + + - name: Build + run: cmake --build build-sanitize --parallel 2 + + - name: Test + env: + ASAN_OPTIONS: detect_leaks=1 + run: ctest --test-dir build-sanitize --output-on-failure + + conformance: + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + timeout-minutes: 90 + concurrency: + group: durable-cpp-conformance-persistent + cancel-in-progress: false + permissions: + contents: read + id-token: write + env: + AWS_REGION: us-west-2 + AWS_DEFAULT_REGION: us-west-2 + CONFORMANCE_COMMIT: 02d6dca971a38c13d94d6233d12f687e55b2a572 + AWS_SDK_CPP_COMMIT: 29656157ab3aec2464a16564b912aa2fdbbf6437 + AWS_LAMBDA_CPP_COMMIT: 30ec0a905cc7e415f4ae7e3a8b898dc8d7c536d0 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.INTEGRATION_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + role-session-name: durable-cpp-${{ github.run_id }} + mask-aws-account-id: true + + - name: Verify integration account + env: + EXPECTED_ACCOUNT: ${{ secrets.INTEGRATION_ACCOUNT }} + run: | + actual_account=$(aws sts get-caller-identity --query Account --output text) + test "$actual_account" = "$EXPECTED_ACCOUNT" + echo "Integration account verified" + + - name: Checkout pinned conformance suite + run: | + git clone --filter=blob:none \ + https://github.com/aws/aws-durable-execution-conformance-tests.git \ + "${RUNNER_TEMP}/conformance" + git -C "${RUNNER_TEMP}/conformance" checkout "${CONFORMANCE_COMMIT}" + python -m pip install \ + "${RUNNER_TEMP}/conformance/packages/aws-durable-execution-conformance-tests" + + - name: Checkout pinned AWS SDK for C++ + run: | + git clone --filter=blob:none --no-checkout \ + https://github.com/aws/aws-sdk-cpp.git \ + "${RUNNER_TEMP}/aws-sdk-cpp" + git -C "${RUNNER_TEMP}/aws-sdk-cpp" sparse-checkout init --cone + git -C "${RUNNER_TEMP}/aws-sdk-cpp" sparse-checkout set \ + cmake \ + toolchains \ + src/aws-cpp-sdk-core \ + generated/src/aws-cpp-sdk-lambda + git -C "${RUNNER_TEMP}/aws-sdk-cpp" checkout "${AWS_SDK_CPP_COMMIT}" + git -C "${RUNNER_TEMP}/aws-sdk-cpp" submodule update \ + --init --recursive crt/aws-crt-cpp + + - name: Checkout pinned aws-lambda-cpp + run: | + git clone --filter=blob:none \ + https://github.com/awslabs/aws-lambda-cpp.git \ + "${RUNNER_TEMP}/aws-lambda-cpp" + git -C "${RUNNER_TEMP}/aws-lambda-cpp" checkout \ + "${AWS_LAMBDA_CPP_COMMIT}" + + - name: Restore Amazon Linux build cache + uses: actions/cache@v4 + with: + path: .cache/conformance-al2023 + key: al2023-v1-${{ runner.os }}-${{ env.AWS_SDK_CPP_COMMIT }}-${{ env.AWS_LAMBDA_CPP_COMMIT }}-${{ hashFiles('scripts/build_conformance_al2023.sh') }}-${{ github.sha }} + restore-keys: al2023-v1-${{ runner.os }}-${{ env.AWS_SDK_CPP_COMMIT }}-${{ env.AWS_LAMBDA_CPP_COMMIT }}-${{ hashFiles('scripts/build_conformance_al2023.sh') }}- + + - name: Build Amazon Linux 2023 package + env: + AWS_SDK_CPP_SOURCE: ${{ runner.temp }}/aws-sdk-cpp + AWS_LAMBDA_CPP_SOURCE: ${{ runner.temp }}/aws-lambda-cpp + BUILD_ROOT: ${{ github.workspace }}/.cache/conformance-al2023 + JOBS: "2" + run: scripts/build_conformance_al2023.sh + + - name: Install SAM CLI container wrapper + run: | + mkdir -p "${RUNNER_TEMP}/bin" + cat >"${RUNNER_TEMP}/bin/sam" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + exec docker run --rm \ + -e AWS_ACCESS_KEY_ID \ + -e AWS_SECRET_ACCESS_KEY \ + -e AWS_SESSION_TOKEN \ + -e AWS_REGION \ + -e AWS_DEFAULT_REGION \ + -e SAM_CLI_TELEMETRY=0 \ + -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ + -v "${RUNNER_TEMP}:${RUNNER_TEMP}" \ + -w "${PWD}" \ + public.ecr.aws/sam/build-provided.al2023 \ + sam "$@" + EOF + chmod +x "${RUNNER_TEMP}/bin/sam" + echo "${RUNNER_TEMP}/bin" >>"${GITHUB_PATH}" + + - name: Run all official conformance requirements + run: | + mkdir -p conformance-output/history + durable-execution-conformance \ + --template conformance/template.json \ + --language cpp \ + --region "${AWS_REGION}" \ + --name "cpp-persistent" \ + --max-workers 4 \ + --suite all \ + --no-cleanup \ + --report console json junit \ + --report-file "${GITHUB_WORKSPACE}/conformance-output/report" \ + --history-dir "${GITHUB_WORKSPACE}/conformance-output/history" \ + --fail-on failed+uncovered + + - name: Upload conformance reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: conformance-${{ github.run_id }} + path: conformance-output/ + if-no-files-found: warn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a475429 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/build/ +/build-*/ +/cmake-build-*/ +/.cache/ +/.clangd/ +__pycache__/ +*.pyc +compile_commands.json +*.gcda +*.gcno +*.profraw +*.profdata +conformance/durable_execution_conformance.zip diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..86fab76 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,324 @@ +cmake_minimum_required(VERSION 3.25) + +project( + aws_durable_execution + VERSION 0.1.0 + DESCRIPTION "High-performance C++ SDK for AWS Lambda durable executions" + LANGUAGES CXX +) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +option(DURABLE_EXECUTION_BUILD_TESTS "Build unit tests" ${PROJECT_IS_TOP_LEVEL}) +option(DURABLE_EXECUTION_BUILD_EXAMPLES "Build examples" ${PROJECT_IS_TOP_LEVEL}) +option(DURABLE_EXECUTION_BUILD_BENCHMARKS "Build microbenchmarks" OFF) +option(DURABLE_EXECUTION_ENABLE_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +option(DURABLE_EXECUTION_BUILD_AWS_SDK_ADAPTER "Build the AWS SDK for C++ Lambda service adapter" OFF) +option(DURABLE_EXECUTION_BUILD_LAMBDA_RUNTIME_ADAPTER "Export the AWS Lambda C++ runtime adapter target" OFF) +option(DURABLE_EXECUTION_BUILD_CONFORMANCE "Build deployable AWS conformance handlers" OFF) + +add_library(aws_durable_execution + src/context.cpp + src/execution_state.cpp + src/local_runner.cpp + src/model.cpp + src/plugin.cpp + src/wire.cpp + src/detail/blake2b.cpp +) +add_library(aws::durable_execution ALIAS aws_durable_execution) +set_target_properties( + aws_durable_execution + PROPERTIES + EXPORT_NAME durable_execution + VERSION "${PROJECT_VERSION}" + SOVERSION 1 +) + +target_compile_features(aws_durable_execution PUBLIC cxx_std_23) +target_include_directories( + aws_durable_execution + PUBLIC + "$" + "$" +) + +if(MSVC) + target_compile_options(aws_durable_execution PRIVATE /W4 /permissive-) +else() + target_compile_options( + aws_durable_execution + PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wshadow + ) +endif() + +if(DURABLE_EXECUTION_ENABLE_SANITIZERS AND NOT MSVC) + target_compile_options(aws_durable_execution PUBLIC -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(aws_durable_execution PUBLIC -fsanitize=address,undefined) +endif() + +set(DURABLE_EXECUTION_PACKAGE_HAS_AWS_SDK OFF) +if(DURABLE_EXECUTION_BUILD_AWS_SDK_ADAPTER) + # The installed AWS SDK Core target exposes ZLIB::ZLIB in its public link + # interface but some SDK releases do not materialize that imported target + # from AWSSDKConfig.cmake. Resolve it first so downstream/static builds work + # consistently, including Amazon Linux Lambda packaging builds. + find_package(ZLIB REQUIRED) + find_package(AWSSDK REQUIRED COMPONENTS lambda) + + add_library( + aws_durable_execution_aws_sdk + src/aws_sdk/service_client.cpp + ) + add_library( + aws::durable_execution_aws_sdk + ALIAS aws_durable_execution_aws_sdk + ) + set_target_properties( + aws_durable_execution_aws_sdk + PROPERTIES + EXPORT_NAME durable_execution_aws_sdk + VERSION "${PROJECT_VERSION}" + SOVERSION 1 + ) + target_compile_features(aws_durable_execution_aws_sdk PUBLIC cxx_std_23) + target_include_directories( + aws_durable_execution_aws_sdk + PUBLIC + "$" + "$" + ) + target_link_libraries( + aws_durable_execution_aws_sdk + PUBLIC + aws::durable_execution + ) + if(TARGET AWS::aws-cpp-sdk-lambda) + target_link_libraries( + aws_durable_execution_aws_sdk + PUBLIC AWS::aws-cpp-sdk-lambda + ) + elseif(TARGET aws-cpp-sdk-lambda) + target_link_libraries( + aws_durable_execution_aws_sdk + PUBLIC aws-cpp-sdk-lambda + ) + elseif(AWSSDK_LINK_LIBRARIES) + target_link_libraries( + aws_durable_execution_aws_sdk + PUBLIC ${AWSSDK_LINK_LIBRARIES} + ) + else() + message(FATAL_ERROR "AWSSDK was found but no Lambda link target was exposed") + endif() + + if(MSVC) + target_compile_options( + aws_durable_execution_aws_sdk + PRIVATE /W4 /permissive- + ) + else() + target_compile_options( + aws_durable_execution_aws_sdk + PRIVATE + -Wall + -Wextra + -Wpedantic + -Wconversion + -Wshadow + ) + endif() + + if(DURABLE_EXECUTION_ENABLE_SANITIZERS AND NOT MSVC) + target_compile_options( + aws_durable_execution_aws_sdk + PUBLIC -fsanitize=address,undefined -fno-omit-frame-pointer + ) + target_link_options( + aws_durable_execution_aws_sdk + PUBLIC -fsanitize=address,undefined + ) + endif() + + install( + TARGETS aws_durable_execution_aws_sdk + EXPORT aws_durable_execution_targets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" + ) + set(DURABLE_EXECUTION_PACKAGE_HAS_AWS_SDK ON) +endif() + +set(DURABLE_EXECUTION_PACKAGE_HAS_LAMBDA_RUNTIME OFF) +if(DURABLE_EXECUTION_BUILD_LAMBDA_RUNTIME_ADAPTER) + find_package(aws-lambda-runtime REQUIRED) + + add_library(aws_durable_execution_lambda_runtime INTERFACE) + add_library( + aws::durable_execution_lambda_runtime + ALIAS aws_durable_execution_lambda_runtime + ) + set_target_properties( + aws_durable_execution_lambda_runtime + PROPERTIES EXPORT_NAME durable_execution_lambda_runtime + ) + target_compile_features( + aws_durable_execution_lambda_runtime + INTERFACE cxx_std_23 + ) + target_include_directories( + aws_durable_execution_lambda_runtime + INTERFACE + "$" + "$" + ) + target_link_libraries( + aws_durable_execution_lambda_runtime + INTERFACE + aws::durable_execution + AWS::aws-lambda-runtime + ) + install( + TARGETS aws_durable_execution_lambda_runtime + EXPORT aws_durable_execution_targets + ) + set(DURABLE_EXECUTION_PACKAGE_HAS_LAMBDA_RUNTIME ON) +endif() + +if(DURABLE_EXECUTION_BUILD_TESTS) + enable_testing() + add_executable(durable_execution_tests tests/test_main.cpp) + target_link_libraries(durable_execution_tests PRIVATE aws::durable_execution) + add_test(NAME durable_execution_tests COMMAND durable_execution_tests) + set_tests_properties( + durable_execution_tests + PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + ) + add_executable( + durable_execution_conformance_local_tests + conformance/local_tests.cpp + ) + target_link_libraries( + durable_execution_conformance_local_tests + PRIVATE aws::durable_execution + ) + target_include_directories( + durable_execution_conformance_local_tests + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" + ) + add_test( + NAME durable_execution_conformance_local_tests + COMMAND durable_execution_conformance_local_tests + ) + find_package(Python3 COMPONENTS Interpreter QUIET) + if(Python3_Interpreter_FOUND) + add_test( + NAME durable_execution_conformance_assets + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/scripts/validate_conformance_assets.py" + ) + endif() +endif() + +if(DURABLE_EXECUTION_BUILD_EXAMPLES) + add_executable(order_workflow examples/order_workflow.cpp) + target_link_libraries(order_workflow PRIVATE aws::durable_execution) + + if( + DURABLE_EXECUTION_BUILD_AWS_SDK_ADAPTER + AND DURABLE_EXECUTION_BUILD_LAMBDA_RUNTIME_ADAPTER + ) + add_executable(order_lambda examples/lambda_main.cpp) + target_link_libraries( + order_lambda + PRIVATE + aws::durable_execution_aws_sdk + aws::durable_execution_lambda_runtime + ) + if(COMMAND aws_lambda_package_target) + aws_lambda_package_target(order_lambda) + endif() + endif() +endif() + +if(DURABLE_EXECUTION_BUILD_CONFORMANCE) + if( + NOT DURABLE_EXECUTION_BUILD_AWS_SDK_ADAPTER + OR NOT DURABLE_EXECUTION_BUILD_LAMBDA_RUNTIME_ADAPTER + ) + message( + FATAL_ERROR + "Conformance handlers require both AWS SDK and Lambda runtime adapters" + ) + endif() + add_executable( + durable_execution_conformance + conformance/main.cpp + ) + target_include_directories( + durable_execution_conformance + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" + ) + target_link_libraries( + durable_execution_conformance + PRIVATE + aws::durable_execution_aws_sdk + aws::durable_execution_lambda_runtime + ) + if(COMMAND aws_lambda_package_target) + aws_lambda_package_target(durable_execution_conformance) + endif() +endif() + +if(DURABLE_EXECUTION_BUILD_BENCHMARKS) + add_executable(durable_execution_microbench benchmarks/microbench.cpp) + target_link_libraries(durable_execution_microbench PRIVATE aws::durable_execution) +endif() + +install( + TARGETS aws_durable_execution + EXPORT aws_durable_execution_targets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" +) +install(DIRECTORY include/ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") +install( + FILES README.md LICENSE NOTICE + DESTINATION "${CMAKE_INSTALL_DOCDIR}" +) +install( + DIRECTORY docs/ + DESTINATION "${CMAKE_INSTALL_DOCDIR}" +) +install( + EXPORT aws_durable_execution_targets + FILE aws_durable_execution_targets.cmake + NAMESPACE aws:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/aws_durable_execution" +) + +configure_package_config_file( + cmake/aws_durable_executionConfig.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/aws_durable_executionConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/aws_durable_execution" +) +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/aws_durable_executionConfigVersion.cmake" + VERSION "${PROJECT_VERSION}" + COMPATIBILITY SameMajorVersion +) +install( + FILES + "${CMAKE_CURRENT_BINARY_DIR}/aws_durable_executionConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/aws_durable_executionConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/aws_durable_execution" +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c819ecc --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of your modifications, or + for any such Derivative Works as a whole, provided your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Zhongke Chen + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..9273857 --- /dev/null +++ b/NOTICE @@ -0,0 +1,12 @@ +AWS Durable Execution SDK for C++ +Copyright 2026 Zhongke Chen + +This product includes behavior and protocol-model work derived from +async-durable-execution, a modified fork of software originally released by +Amazon.com, Inc. or its affiliates under the Apache License 2.0. + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +Copyright 2026 Zhongke Chen + +Reference project: +https://github.com/zhongkechen/async-durable-execution diff --git a/README.md b/README.md new file mode 100644 index 0000000..db44de0 --- /dev/null +++ b/README.md @@ -0,0 +1,295 @@ +# AWS Durable Execution SDK for C++ + +A dependency-free C++23 foundation for building high-performance AWS Lambda +durable executions. The API follows C++ value semantics and templates for hot +paths while retaining the replay and checkpoint behavior of +[`async-durable-execution`](https://github.com/zhongkechen/async-durable-execution). + +> **Project status: early development.** The replay/checkpoint core, durable +> steps, waits, retries, deterministic IDs, protocol models, and service-client +> boundary are implemented. The dependency-free Lambda wire codec, optional AWS +> SDK for C++ transport, and optional `aws-lambda-cpp` handler adapter are also +> implemented and compile-checked against the current official headers. +> Durable callbacks, child contexts, chained invoke, and wait-for-callback are +> implemented. Parallel/map composition and contention-aware checkpoint +> batching are implemented with deterministic branch IDs. Wait-for-condition, +> whole-block retry, replay-safe values, and recursive invoke are implemented. +> Typed declarative flow/DAG composition is implemented with pre-checkpoint +> validation and durable node isolation. A deterministic local runner provides +> virtual time, callback controls, mocked invokes, and inspectable history. +> Versioned instrumentation plugins expose invocation, operation, attempt, +> replay, and state-change lifecycle hooks with per-plugin fault isolation. +> Independent reference fixtures and the complete upstream conformance handler +> matrix are integrated. Deployed AWS conformance runs through the repository +> integration-test role. + +## Design goals + +- C++23 with strong types, RAII, `std::expected`, `std::chrono`, concepts, and + zero-cost serializer customization. +- Performance close to C for local hot paths. Known wire enums and history + lookups do not allocate; serializers and operations are statically dispatched. +- Deterministic replay with a stable, SDK-owned BLAKE2b operation-ID scheme. + The algorithm and truncation are part of this SDK's durable-history contract. +- Backward and forward compatibility built into the public model. ABI symbols + use the inline `v1` namespace; wire enums retain unknown future AWS values. +- AWS integration behind `service_client`, keeping network and AWS SDK types out + of workflow code and the core library. +- A specialized wire parser skips unknown fields without constructing a general + JSON DOM and decodes a minimal durable invocation in under one microsecond on + the current development host. + +## Current API + +```cpp +#include +#include +#include + +#include + +namespace durable = aws::durable_execution; +using namespace std::chrono_literals; + +durable::invocation_output handle( + const durable::invocation_input& input, + durable::service_client& aws_client) { + return durable::run(input, aws_client, [](std::string_view event) { + const int validation = durable::step( + [event] { + // Nondeterministic I/O belongs inside a durable step. + return event.empty() ? 0 : 1; + }, + durable::step_config{.name = "validate_order"}); + + if (validation == 0) { + return std::string{"rejected"}; + } + + durable::wait(5s, "await_confirmation"); + return std::string{"approved"}; + }); +} +``` + +`run` accepts handlers taking `(durable_context&, std::string_view)`, +`std::string_view`, `durable_context&`, or no arguments. Primitive results use +the built-in JSON serializers. Applications provide a serializer object for +domain types; its calls are statically dispatched. + +AWS-native durable primitives use typed C++ handles and `std::optional` for +backend results that may be absent: + +```cpp +auto callback = durable::create_callback( + durable::callback_config{.name = "approval", .timeout = 1h}); +send_approval_request(callback.callback_id()); +const auto approval = callback.result(); // suspends until completed + +const auto downstream = durable::invoke( + "worker:prod", std::string{"payload"}, + durable::invoke_config{.name = "worker-call"}); + +const int child_result = durable::run_in_child_context( + [] { return durable::step([] { return 42; }); }, + durable::child_context_config{.name = "calculation"}); +``` + +`wait_for_callback` composes callback creation, a checkpointed submitter step, +and callback suspension inside a child context. Its submitter receives the +callback ID directly: + +```cpp +const auto approval = durable::wait_for_callback( + [](std::string_view callback_id) { + submit_for_external_approval(callback_id); + }, + durable::wait_for_callback_config{.name = "approval"}); +``` + +Parallel and map operations use bounded workers, checkpoint batching, and +durable child contexts: + +```cpp +auto prices = durable::map( + [](const LineItem& item) { + return durable::step([&] { return fetch_price(item); }); + }, + line_items, + durable::map_config{.name = "price-items", .max_concurrency = 8}); + +auto first = durable::parallel( + std::tuple{query_primary, query_replica}, + durable::parallel_config{ + .name = "race-replicas", + .completion = durable::completion_config::first_successful()}); +``` + +Replay-safe helpers checkpoint nondeterministic values through normal steps: + +```cpp +const double sample = durable::replay_safe::random(); +const auto created_at = durable::replay_safe::now(); +const durable::uuid_value request_id = durable::replay_safe::uuid(); +``` + +Stateful polling and whole-block retry expose the attempt directly to the +callable: + +```cpp +auto state = durable::wait_for_condition( + poll, + PollState{}, + durable::polling_strategy{.max_attempts = 20}); + +auto result = durable::with_retry( + [](std::uint32_t attempt) { return run_workflow_attempt(attempt); }, + durable::with_retry_config{.name = "workflow-retry"}); +``` + +`recurse` records a chained self-invocation rather than growing the C++ stack: + +```cpp +auto child = durable::recurse_json( + R"({"remaining":9})", + durable::recurse_config{.with_recursive_level = true}); +``` + +Declarative graphs use typed node handles and immutable dependency expressions: + +```cpp +durable::flow_builder graph; +auto load = graph.node("load", load_order); +auto price = graph.node( + "price", + [load](durable::flow_node_context& context) { + return price_order(context.outcome(load)); + }); +graph.depends_on(price, load.succeeded()); +graph.outputs(price.outcome()); + +auto result = durable::flow( + graph, durable::flow_config{.name = "order-flow"}); +const Money total = result.output(); +``` + +Local tests run without AWS credentials or wall-clock waits: + +```cpp +auto runner = durable::make_local_runner( + order_handler, + durable::local_runner_options{ + .input_json = R"({"order_id":"order-123"})"}); + +auto result = runner.run(); +if (result.status() == durable::local_run_status::pending_external) { + const auto callback_id = result.pending_callback_ids().front(); + runner.send_callback_success(callback_id, R"({"approved":true})"); + result = runner.resume(); +} + +assert(result.status() == durable::local_run_status::succeeded); +assert(result.step("validate-order") != nullptr); +``` + +See [docs/local-runner.md](docs/local-runner.md). +Reference fixtures and conformance coverage are documented in +[docs/conformance.md](docs/conformance.md). +Instrumentation plugins are documented in [docs/plugins.md](docs/plugins.md). +Third-party durable primitives are documented in +[docs/custom-operations.md](docs/custom-operations.md). + +See [docs/operations.md](docs/operations.md) for replay and result semantics. +See [docs/concurrency.md](docs/concurrency.md) for scheduling, batching, and +early-completion behavior. + +For a raw Lambda runtime payload, `run_json` performs tolerant wire decoding, +runs the durable handler, and serializes the invocation response: + +```cpp +auto response = durable::run_json(request.payload, service, durable_handler); +``` + +## AWS integration + +The core remains dependency-free. Enable either optional integration explicitly: + +```console +cmake -S . -B build \ + -DDURABLE_EXECUTION_BUILD_AWS_SDK_ADAPTER=ON \ + -DDURABLE_EXECUTION_BUILD_LAMBDA_RUNTIME_ADAPTER=ON +``` + +The AWS SDK adapter uses `CheckpointDurableExecution` and +`GetDurableExecutionState`. Applications retain responsibility for +`Aws::InitAPI`/`Aws::ShutdownAPI` and the lifetime/configuration of the Lambda +client. + +```cpp +auto lambda_client = std::make_shared(); +durable::aws_sdk_service_client service{lambda_client}; +auto handler = durable::make_lambda_handler(service, durable_function); +aws::lambda_runtime::run_handler(handler); +``` + +See [examples/lambda_main.cpp](examples/lambda_main.cpp) and +[docs/aws-integration.md](docs/aws-integration.md). + +## Build and test + +```console +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel +ctest --test-dir build --output-on-failure +``` + +Enable the self-contained microbenchmarks with: + +```console +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ + -DDURABLE_EXECUTION_BUILD_BENCHMARKS=ON +cmake --build build --parallel +./build/durable_execution_microbench +``` + +On the current development host, the initial core measures roughly 370 ns per +operation-ID generation, 20 ns per integer serialize / +deserialize round trip, 800 ns to decode a minimal invocation containing one +operation, and 34 ns for a thread-safe history lookup. These figures are +development baselines, not portable performance guarantees. + +## Install and consume + +```console +cmake --install build --prefix /your/prefix +``` + +```cmake +find_package(aws_durable_execution 0.1 CONFIG REQUIRED) +target_link_libraries(your_target PRIVATE aws::durable_execution) +``` + +## Compatibility + +The compatibility contract covers source, ABI, durable history, wire protocol, +and serialized payloads. Unknown wire enum values are retained without +allocation for known values, and unsafe unknown operation states fail closed +instead of accidentally rerunning user code. + +See [docs/compatibility.md](docs/compatibility.md) for the policy and +[docs/architecture.md](docs/architecture.md) for the layer boundaries and +performance constraints. + +## Roadmap + +1. Run all 171 mapped requirements against deployed AWS Lambda durable + functions. +2. Retain replay fixtures from every released minor version and verify them in + CI as the SDK evolves. +3. Stable 1.0 API/ABI after every required conformance, compatibility, and + performance gate + passes. + +## License + +Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/benchmarks/microbench.cpp b/benchmarks/microbench.cpp new file mode 100644 index 0000000..c7d940f --- /dev/null +++ b/benchmarks/microbench.cpp @@ -0,0 +1,183 @@ +#include +#include +#include +#include +#include + +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/execution_state.hpp" +#include "aws/durable_execution/flow.hpp" +#include "aws/durable_execution/local_runner.hpp" +#include "aws/durable_execution/serdes.hpp" +#include "aws/durable_execution/wire.hpp" + +namespace durable = aws::durable_execution; + +namespace { + +class benchmark_service_client final : public durable::service_client { + public: + std::expected checkpoint( + const durable::checkpoint_request&) override { + return durable::checkpoint_output{}; + } + + std::expected + get_execution_state(const durable::get_state_request&) override { + return durable::state_output{}; + } +}; + +} // namespace + +int main() { + constexpr std::size_t iterations = 1'000'000; + std::size_t checksum = 0; + + const auto id_start = std::chrono::steady_clock::now(); + durable::operation_id_generator generator{"benchmark"}; + for (std::size_t index = 0; index < iterations; ++index) { + checksum += generator.next().front(); + } + const auto id_elapsed = std::chrono::steady_clock::now() - id_start; + + const auto serde_start = std::chrono::steady_clock::now(); + durable::default_serdes serializer; + const durable::serdes_context context{}; + for (std::size_t index = 0; index < iterations; ++index) { + const auto encoded = serializer.serialize(index, context); + checksum += serializer.deserialize(encoded, context); + } + const auto serde_elapsed = std::chrono::steady_clock::now() - serde_start; + + constexpr std::string_view invocation = R"({ + "DurableExecutionArn":"arn:aws:lambda:region:account:function:name/execution", + "CheckpointToken":"token", + "InitialExecutionState":{"Operations":[{ + "Id":"execution","Type":"EXECUTION","Status":"STARTED", + "ExecutionDetails":{"InputPayload":"{\"value\":42}"} + }]} + })"; + constexpr std::size_t wire_iterations = 250'000; + const auto wire_start = std::chrono::steady_clock::now(); + for (std::size_t index = 0; index < wire_iterations; ++index) { + const auto decoded = durable::decode_invocation_input(invocation); + if (!decoded) { + return 2; + } + checksum += decoded->initial_state.operations.size(); + } + const auto wire_elapsed = std::chrono::steady_clock::now() - wire_start; + + benchmark_service_client service; + durable::execution_state state{ + "arn:aws:lambda:region:account:function:name/execution", "token", + service}; + state.initialize(durable::initial_execution_state{ + .operations = { + durable::operation{ + .operation_id = "execution", + .type = durable::operation_type::execution, + .status = durable::operation_status::started, + }, + durable::operation{ + .operation_id = "lookup-operation", + .type = durable::operation_type::step, + .status = durable::operation_status::succeeded, + .sub_type = + std::string{durable::operation_subtype::step}, + .step = durable::step_details{.result = "42"}, + }, + }, + }); + const auto lookup_start = std::chrono::steady_clock::now(); + for (std::size_t index = 0; index < iterations; ++index) { + const auto operation = state.find_operation("lookup-operation"); + checksum += operation ? operation->operation_id.size() : 0U; + } + const auto lookup_elapsed = + std::chrono::steady_clock::now() - lookup_start; + + constexpr std::size_t graph_iterations = 10'000; + constexpr std::size_t graph_nodes = 16; + const auto graph_start = std::chrono::steady_clock::now(); + for (std::size_t iteration = 0; iteration < graph_iterations; + ++iteration) { + durable::flow_builder builder; + std::vector> nodes; + nodes.reserve(graph_nodes); + for (std::size_t index = 0; index < graph_nodes; ++index) { + auto node = builder.node( + "node-" + std::to_string(index), + [index] { return static_cast(index); }); + if (!nodes.empty()) { + builder.depends_on(node, nodes.back().succeeded()); + } + nodes.push_back(node); + } + builder.outputs(nodes.back().outcome()); + const auto definition = builder.freeze(); + checksum += definition->execution_nodes.size(); + } + const auto graph_elapsed = + std::chrono::steady_clock::now() - graph_start; + + constexpr std::size_t runner_iterations = 10'000; + const auto runner_start = std::chrono::steady_clock::now(); + for (std::size_t index = 0; index < runner_iterations; ++index) { + auto runner = durable::make_local_runner([] { + return durable::step([] { return 42; }); + }); + const auto result = runner.run(); + if (result.status() != durable::local_run_status::succeeded) { + return 3; + } + checksum += static_cast( + result.deserialize_result()); + } + const auto runner_elapsed = + std::chrono::steady_clock::now() - runner_start; + + const auto ids_ns = std::chrono::duration_cast( + id_elapsed) + .count(); + const auto serde_ns = std::chrono::duration_cast( + serde_elapsed) + .count(); + const auto wire_ns = std::chrono::duration_cast( + wire_elapsed) + .count(); + const auto lookup_ns = std::chrono::duration_cast( + lookup_elapsed) + .count(); + const auto graph_ns = std::chrono::duration_cast( + graph_elapsed) + .count(); + const auto runner_ns = std::chrono::duration_cast( + runner_elapsed) + .count(); + + std::cout << "operation_id_generation_ns/op=" + << static_cast(ids_ns) / static_cast(iterations) + << '\n' + << "integer_serdes_round_trip_ns/op=" + << static_cast(serde_ns) / static_cast(iterations) + << '\n' + << "invocation_wire_decode_ns/op=" + << static_cast(wire_ns) / + static_cast(wire_iterations) + << '\n' + << "thread_safe_history_lookup_ns/op=" + << static_cast(lookup_ns) / + static_cast(iterations) + << '\n' + << "flow_build_validate_ns/node=" + << static_cast(graph_ns) / + static_cast(graph_iterations * graph_nodes) + << '\n' + << "local_runner_step_ns/run=" + << static_cast(runner_ns) / + static_cast(runner_iterations) + << '\n' + << "checksum=" << checksum << '\n'; +} diff --git a/cmake/aws_durable_executionConfig.cmake.in b/cmake/aws_durable_executionConfig.cmake.in new file mode 100644 index 0000000..cae028c --- /dev/null +++ b/cmake/aws_durable_executionConfig.cmake.in @@ -0,0 +1,15 @@ +@PACKAGE_INIT@ + +if(@DURABLE_EXECUTION_PACKAGE_HAS_AWS_SDK@) + include(CMakeFindDependencyMacro) + find_dependency(ZLIB) + find_dependency(AWSSDK COMPONENTS lambda) +endif() + +if(@DURABLE_EXECUTION_PACKAGE_HAS_LAMBDA_RUNTIME@) + include(CMakeFindDependencyMacro) + find_dependency(aws-lambda-runtime) +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/aws_durable_execution_targets.cmake") +check_required_components(aws_durable_execution) diff --git a/conformance/coverage.json b/conformance/coverage.json new file mode 100644 index 0000000..6e85aaa --- /dev/null +++ b/conformance/coverage.json @@ -0,0 +1,179 @@ +{ + "total": 171, + "supported": 171, + "unsupported": 0, + "supported_ids": [ + "1-1", + "1-2", + "1-3", + "1-4", + "1-5", + "1-6", + "1-7", + "1-8", + "1-9", + "1-10", + "1-11", + "1-12", + "1-13", + "1-14", + "1-15", + "1-16", + "1-17", + "1-18", + "1-19", + "1-20", + "2-1", + "2-2", + "2-3", + "2-4", + "2-5", + "3-1", + "3-2", + "3-3", + "3-4", + "3-5", + "3-6", + "3-7", + "3-8", + "3-9", + "3-10", + "3-11", + "3-12", + "3-13", + "3-14", + "3-15", + "3-16", + "3-17", + "3-18", + "4-1", + "4-2", + "4-3", + "4-4", + "4-5", + "4-6", + "4-7", + "4-8", + "4-9", + "4-10", + "4-11", + "4-12", + "4-13", + "4-14", + "4-15", + "4-16", + "4-17", + "4-18", + "4-19", + "5-1", + "5-2", + "5-3", + "5-4", + "5-5", + "5-6", + "5-7", + "5-8", + "5-9", + "5-10", + "5-11", + "5-12", + "5-13", + "5-14", + "5-15", + "5-16", + "6-1", + "6-2", + "6-3", + "6-4", + "6-5", + "6-6", + "6-7", + "6-8", + "6-9", + "6-10", + "6-11", + "6-12", + "6-13", + "7-1", + "7-2", + "7-3", + "7-4", + "7-5", + "7-6", + "7-7", + "7-8", + "7-9", + "7-10", + "7-11", + "7-12", + "7-13", + "7-14", + "7-15", + "8-1", + "8-2", + "8-3", + "8-4", + "8-5", + "8-6", + "8-7", + "8-8", + "8-9", + "8-10", + "8-11", + "8-12", + "8-13", + "8-14", + "8-15", + "8-16", + "8-17", + "8-18", + "8-19", + "8-20", + "8-21", + "8-22", + "9-1", + "9-2", + "9-3", + "9-4", + "9-5", + "9-6", + "9-7", + "9-8", + "9-9", + "9-10", + "9-11", + "9-12", + "9-13", + "9-14", + "9-15", + "9-16", + "9-17", + "9-18", + "9-19", + "9-20", + "10-1", + "10-2", + "10-3", + "10-4", + "10-5", + "10-6", + "10-7", + "10-8", + "10-9", + "10-10", + "10-11", + "10-12", + "10-13", + "10-14", + "10-15", + "10-16", + "10-17", + "10-18", + "10-19", + "10-20", + "10-21", + "10-22", + "10-23" + ], + "unsupported_ids": [] +} diff --git a/conformance/handlers.hpp b/conformance/handlers.hpp new file mode 100644 index 0000000..ae1a444 --- /dev/null +++ b/conformance/handlers.hpp @@ -0,0 +1,2357 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/durable_execution.hpp" + +namespace durable_conformance { + +namespace durable = aws::durable_execution; +using namespace std::chrono_literals; + +struct environment { + std::string target_function_name; + std::string error_function_name; +}; + +class TransientError final : public std::runtime_error { + public: + using std::runtime_error::runtime_error; +}; + +inline void skip_whitespace(std::string_view input, std::size_t& index) { + while (index < input.size() && + (input[index] == ' ' || input[index] == '\t' || + input[index] == '\r' || input[index] == '\n')) { + ++index; + } +} + +inline std::vector parse_string_array( + std::string_view input) { + std::vector values; + std::size_t index = 0; + skip_whitespace(input, index); + if (index >= input.size() || input[index++] != '[') { + throw std::invalid_argument("Expected a JSON string array"); + } + skip_whitespace(input, index); + if (index < input.size() && input[index] == ']') { + return values; + } + while (index < input.size()) { + skip_whitespace(input, index); + if (index >= input.size() || input[index] != '"') { + throw std::invalid_argument("Expected a JSON string array item"); + } + const std::size_t start = index++; + bool escaped = false; + while (index < input.size()) { + const char value = input[index++]; + if (escaped) { + escaped = false; + } else if (value == '\\') { + escaped = true; + } else if (value == '"') { + break; + } + } + values.push_back( + durable::default_serdes{}.deserialize( + input.substr(start, index - start), {})); + skip_whitespace(input, index); + if (index < input.size() && input[index] == ']') { + ++index; + skip_whitespace(input, index); + if (index != input.size()) { + throw std::invalid_argument("Trailing JSON array data"); + } + return values; + } + if (index >= input.size() || input[index++] != ',') { + throw std::invalid_argument("Expected a JSON array separator"); + } + } + throw std::invalid_argument("Unterminated JSON string array"); +} + +inline std::string json_string_field( + std::string_view input, std::string_view field) { + const std::string key = + std::string{"\""} + std::string{field} + "\""; + std::size_t index = input.find(key); + if (index == std::string_view::npos) { + throw std::invalid_argument( + "Missing JSON string field: " + std::string{field}); + } + index += key.size(); + skip_whitespace(input, index); + if (index >= input.size() || input[index++] != ':') { + throw std::invalid_argument( + "Invalid JSON field separator: " + std::string{field}); + } + skip_whitespace(input, index); + if (index >= input.size() || input[index] != '"') { + throw std::invalid_argument( + "Expected JSON string field: " + std::string{field}); + } + const std::size_t start = index++; + bool escaped = false; + while (index < input.size()) { + const char value = input[index++]; + if (escaped) { + escaped = false; + } else if (value == '\\') { + escaped = true; + } else if (value == '"') { + return durable::default_serdes{}.deserialize( + input.substr(start, index - start), {}); + } + } + throw std::invalid_argument( + "Unterminated JSON string field: " + std::string{field}); +} + +inline int json_int_field( + std::string_view input, std::string_view field) { + const std::string key = + std::string{"\""} + std::string{field} + "\""; + std::size_t index = input.find(key); + if (index == std::string_view::npos) { + throw std::invalid_argument( + "Missing JSON integer field: " + std::string{field}); + } + index += key.size(); + skip_whitespace(input, index); + if (index >= input.size() || input[index++] != ':') { + throw std::invalid_argument( + "Invalid JSON field separator: " + std::string{field}); + } + skip_whitespace(input, index); + int value = 0; + const auto [end, error] = std::from_chars( + input.data() + index, input.data() + input.size(), value); + if (error != std::errc{}) { + throw std::invalid_argument( + "Invalid JSON integer field: " + std::string{field}); + } + (void)end; + return value; +} + +inline std::uint64_t stable_hash(std::string_view value) noexcept { + std::uint64_t hash = 14695981039346656037ULL; + for (const unsigned char character : value) { + hash ^= character; + hash *= 1099511628211ULL; + } + return hash; +} + +inline void emit_execution_log( + std::string_view message, std::string_view execution_arn) { + static std::mutex output_mutex; + std::string line{"{\"message\":"}; + line.append( + durable::default_serdes{}.serialize( + std::string{message}, {})); + line.append(",\"durableExecutionArn\":"); + line.append( + durable::default_serdes{}.serialize( + std::string{execution_arn}, {})); + line.push_back('}'); + std::lock_guard lock{output_mutex}; + std::cout << line << std::endl; +} + +inline void emit_execution_log(std::string_view message) { + emit_execution_log( + message, + durable::current_context().state().durable_execution_arn()); +} + +inline std::string interrupted_marker_path(std::string_view arn) { + return "/tmp/aws-durable-cpp-child-interrupted-" + + std::to_string(stable_hash(arn)); +} + +inline std::string serialize_string_array( + const std::vector& values) { + std::string output{"["}; + bool first = true; + for (const auto& value : values) { + if (!first) output.push_back(','); + first = false; + output.append( + durable::default_serdes{}.serialize(value, {})); + } + output.push_back(']'); + return output; +} + +inline std::vector parse_int_array(std::string_view input) { + std::vector values; + std::size_t index = 0; + skip_whitespace(input, index); + if (index >= input.size() || input[index++] != '[') { + throw std::invalid_argument("Expected a JSON integer array"); + } + while (true) { + skip_whitespace(input, index); + if (index < input.size() && input[index] == ']') { + return values; + } + const char* start = input.data() + index; + int value = 0; + const auto [end, error] = std::from_chars( + start, input.data() + input.size(), value); + if (error != std::errc{}) { + throw std::invalid_argument("Invalid JSON integer array item"); + } + values.push_back(value); + index = static_cast(end - input.data()); + skip_whitespace(input, index); + if (index < input.size() && input[index] == ']') { + return values; + } + if (index >= input.size() || input[index++] != ',') { + throw std::invalid_argument("Expected JSON integer separator"); + } + } +} + +inline std::string serialize_int_array(const std::vector& values) { + std::string output{"["}; + bool first = true; + for (const int value : values) { + if (!first) output.push_back(','); + first = false; + output.append(std::to_string(value)); + } + output.push_back(']'); + return output; +} + +inline std::string serialize_raw_array( + const std::vector& values) { + std::string output{"["}; + bool first = true; + for (const auto& value : values) { + if (!first) output.push_back(','); + first = false; + output.append(value); + } + output.push_back(']'); + return output; +} + +template +std::string batch_projection( + const durable::batch_result& result, + std::initializer_list fields) { + std::string output{"{"}; + bool first = true; + auto append = [&](std::string_view name, std::string value) { + if (!first) output.push_back(','); + first = false; + output.push_back('"'); + output.append(name); + output.append("\":"); + output.append(value); + }; + for (const auto field : fields) { + if (field == "completionReason") { + append( + field, + durable::default_serdes{}.serialize( + std::string{durable::to_string(result.reason)}, {})); + } else if (field == "status") { + append( + field, + durable::default_serdes{}.serialize( + std::string{durable::to_string(result.status())}, {})); + } else if (field == "successCount") { + append(field, std::to_string(result.success_count())); + } else if (field == "failureCount") { + append(field, std::to_string(result.failure_count())); + } else if (field == "totalCount") { + append(field, std::to_string(result.total_count())); + } else if (field == "hasFailure") { + append(field, result.has_failure() ? "true" : "false"); + } else if (field == "errorCount") { + append(field, std::to_string(result.errors().size())); + } + } + output.push_back('}'); + return output; +} + +struct wrapped_object_serdes { + std::string serialize( + const std::string& value, + const durable::serdes_context&) const { + return std::string{"{\"wrapped\":"} + + durable::default_serdes{}.serialize(value, {}) + + "}"; + } + std::string deserialize( + std::string_view data, + const durable::serdes_context&) const { + constexpr std::string_view prefix{"{\"wrapped\":"}; + if (!data.starts_with(prefix) || data.back() != '}') { + throw std::runtime_error("Invalid wrapped object payload"); + } + return durable::default_serdes{}.deserialize( + data.substr(prefix.size(), data.size() - prefix.size() - 1U), + {}); + } +}; + +struct wrapped_prefix_serdes { + std::string serialize( + const std::string& value, + const durable::serdes_context&) const { + return "wrapped:" + value; + } + std::string deserialize( + std::string_view data, + const durable::serdes_context&) const { + constexpr std::string_view prefix{"wrapped:"}; + if (!data.starts_with(prefix)) { + throw std::runtime_error("Invalid wrapped prefix payload"); + } + return std::string{data.substr(prefix.size())}; + } +}; + +struct uppercase_serdes { + std::string serialize( + const std::string& value, + const durable::serdes_context&) const { + std::string output = value; + std::ranges::transform( + output, output.begin(), [](unsigned char character) { + return static_cast(std::toupper(character)); + }); + return output; + } + std::string deserialize( + std::string_view data, + const durable::serdes_context&) const { + return std::string{data}; + } +}; + +struct callback_object_serdes { + std::string serialize( + const std::string& value, + const durable::serdes_context&) const { + return value; + } + std::string deserialize( + std::string_view data, + const durable::serdes_context&) const { + const auto id = json_string_field(data, "id"); + return std::string{"{\"received\":{\"id\":"} + + durable::default_serdes{}.serialize(id, {}) + + R"(,"message":"hello","timestamp":1767225600}})"; + } +}; + +struct uppercase_json_serdes { + std::string serialize( + const std::string& value, + const durable::serdes_context&) const { + return durable::default_serdes{}.serialize(value, {}); + } + std::string deserialize( + std::string_view data, + const durable::serdes_context&) const { + std::string value{data}; + std::ranges::transform( + value, value.begin(), [](unsigned char character) { + return static_cast(std::toupper(character)); + }); + return value; + } +}; + +struct uppercase_payload_serdes { + std::string serialize( + const std::string& value, + const durable::serdes_context&) const { + std::string transformed = value; + std::ranges::transform( + transformed, transformed.begin(), + [](unsigned char character) { + return static_cast(std::toupper(character)); + }); + return durable::default_serdes{}.serialize( + transformed, {}); + } + std::string deserialize( + std::string_view data, + const durable::serdes_context&) const { + return durable::default_serdes{}.deserialize(data, {}); + } +}; + +struct condition_state { + std::string status; + int attempts{}; +}; + +struct condition_state_serdes { + std::string serialize( + const condition_state& value, + const durable::serdes_context&) const { + return std::string{"{\"status\":"} + + durable::default_serdes{}.serialize( + value.status, {}) + + ",\"attempts\":" + std::to_string(value.attempts) + "}"; + } + condition_state deserialize( + std::string_view data, + const durable::serdes_context&) const { + return condition_state{ + .status = json_string_field(data, "status"), + .attempts = json_int_field(data, "attempts"), + }; + } +}; + +struct status_field_serdes { + std::string serialize( + const std::string& value, + const durable::serdes_context&) const { + return durable::default_serdes{}.serialize(value, {}); + } + std::string deserialize( + std::string_view data, + const durable::serdes_context&) const { + std::size_t index = 0; + skip_whitespace(data, index); + if (index < data.size() && data[index] == '{') { + return json_string_field(data, "status"); + } + return durable::default_serdes{}.deserialize(data, {}); + } +}; + +struct map_operation_serdes { + std::string serialize( + const durable::batch_result& result, + const durable::serdes_context&) const { + const auto values = result.results(); + std::string output{"OPSERDE:"}; + for (std::size_t index = 0; index < values.size(); ++index) { + if (index != 0U) output.push_back(','); + output.append(values[index]); + } + return output; + } + durable::batch_result deserialize( + std::string_view data, + const durable::serdes_context&) const { + constexpr std::string_view prefix{"OPSERDE:"}; + if (!data.starts_with(prefix)) { + throw std::runtime_error("Invalid operation serde payload"); + } + durable::batch_result result; + result.reason = durable::completion_reason::all_completed; + std::string_view values = data.substr(prefix.size()); + std::size_t index = 0; + while (!values.empty()) { + const auto comma = values.find(','); + const auto item = + comma == std::string_view::npos ? values : values.substr(0, comma); + result.all.push_back(durable::batch_item{ + .index = index++, + .status = durable::batch_item_status::succeeded, + .result = std::string{item}, + }); + if (comma == std::string_view::npos) break; + values.remove_prefix(comma + 1U); + } + return result; + } +}; + +inline std::string dispatch( + std::string_view test_case, std::string_view event, + const environment& env = {}) { + if (test_case == "target_echo") { + return std::string{event}; + } + if (test_case == "target_error") { + throw std::runtime_error{"target function failed"}; + } + if (test_case == "step_basic") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::step( + [&] { return std::string{"Hello, "} + name + "!"; }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_named") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::step( + [&] { return std::string{"Hello, "} + name + "!"; }, + durable::step_config{.name = "custom_step_name"}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_sequential") { + const auto first = durable::step( + [] { return std::string{"first"}; }); + const auto second = durable::step( + [&] { return first + "_second"; }); + return durable::default_serdes{}.serialize(second, {}); + } + if (test_case == "step_complex") { + const std::string result = + R"({"user":{"name":"Alice","tags":["admin","active"]},"count":2})"; + return durable::step( + [result] { return result; }, durable::passthrough_serdes{}); + } + if (test_case == "step_null") { + durable::step([] {}); + return "null"; + } + if (test_case == "step_custom_serdes") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = + durable::step([&] { return input; }, uppercase_serdes{}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_logger") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto execution_arn = + durable::current_context().state().durable_execution_arn(); + const auto result = durable::step([&] { + emit_execution_log( + "Greeting step started for: " + input, execution_arn); + const std::string greeting = "Hello, " + input + "!"; + emit_execution_log( + "Greeting step completed with: " + greeting, + execution_arn); + return greeting; + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_then_wait" || + test_case == "step_replay_skips") { + const std::string value = + test_case == "step_then_wait" ? "computed" : "cached_value"; + const auto result = durable::step([&] { return value; }); + durable::wait( + test_case == "step_then_wait" ? 2s : 1s); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_replay_rethrows") { + try { + (void)durable::step( + []() -> int { throw std::runtime_error{"cached failure"}; }, + durable::step_config{ + .retry = durable::retry_strategy::none(), + }); + } catch (const std::exception&) { + } + durable::wait(1s); + return "null"; + } + if (test_case == "step_retry_once") { + const auto result = durable::step( + [](std::uint32_t attempt) -> std::string { + if (attempt == 1U) { + throw std::runtime_error{"first attempt failed"}; + } + return "Operation succeeded"; + }, + durable::step_config{ + .retry = durable::retry_strategy{ + .max_attempts = 3, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_retry_exhaustion") { + (void)durable::step( + [](std::uint32_t) -> int { + throw std::runtime_error{"always fails"}; + }, + durable::step_config{ + .retry = durable::retry_strategy{ + .max_attempts = 4, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + return "null"; + } + if (test_case == "step_default_retry") { + const auto result = durable::step( + [](std::uint32_t attempt) { + if (attempt < 3U) { + throw std::runtime_error{"transient"}; + } + return std::string{"succeeded"}; + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_custom_retry") { + const auto result = durable::step( + [](std::uint32_t attempt) { + if (attempt < 3U) { + throw std::runtime_error{"transient"}; + } + return std::string{"succeeded"}; + }, + durable::step_config{ + .retry = durable::retry_strategy{ + .max_attempts = 5, + .initial_delay = 2s, + .max_delay = 60s, + .backoff_rate = 3.0, + .jitter = durable::jitter_strategy::none, + }, + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_retry_specific") { + const auto result = durable::step( + [](std::uint32_t attempt) { + if (attempt == 1U) { + throw TransientError{"retry me"}; + } + return std::string{"succeeded"}; + }, + durable::step_config{ + .retry_decider = + [](const std::exception& error, std::uint32_t) { + return dynamic_cast(&error) + ? std::optional{1s} + : std::optional{}; + }, + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_non_retryable") { + (void)durable::step( + []() -> int { throw TransientError{"do not retry"}; }, + durable::step_config{ + .retry_decider = + [](const std::exception&, std::uint32_t) { + return std::optional{}; + }, + }); + return "null"; + } + if (test_case == "step_at_most_crash" || + test_case == "step_at_most_retry") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto execution_arn = + durable::current_context().state().durable_execution_arn(); + const auto result = durable::step( + [&](std::uint32_t attempt) -> std::string { + emit_execution_log(input, execution_arn); + if (attempt == 1U) { + std::_Exit(1); + } + return "succeeded on second attempt"; + }, + durable::step_config{ + .name = + test_case == "step_at_most_crash" + ? std::optional{ + "at_most_once_flaky_step"} + : std::nullopt, + .retry = + test_case == "step_at_most_crash" + ? durable::retry_strategy::none() + : durable::retry_strategy{ + .max_attempts = 3, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + .semantics = + durable::step_semantics::at_most_once_per_retry, + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "step_permanent_error") { + (void)durable::step( + []() -> int { throw std::runtime_error{"permanent"}; }, + durable::step_config{ + .retry = durable::retry_strategy::none(), + }); + return "null"; + } + if (test_case == "step_error_caught") { + try { + (void)durable::step( + []() -> int { throw std::runtime_error{"expected"}; }, + durable::step_config{ + .retry = durable::retry_strategy::none(), + }); + } catch (const std::exception&) { + } + const auto fallback = + durable::step([] { return std::string{"fallback_result"}; }); + return durable::default_serdes{}.serialize(fallback, {}); + } + if (test_case == "wait_basic") { + durable::wait(2s); + return "null"; + } + if (test_case == "wait_named") { + durable::wait(2s, "custom_wait_name"); + return "null"; + } + if (test_case == "wait_sequential") { + durable::wait(2s, "wait-1"); + durable::wait(2s, "wait-2"); + return R"({"completedWaits":2})"; + } + if (test_case == "wait_minutes") { + durable::wait(60s); + return "null"; + } + if (test_case == "wait_hour") { + durable::wait(std::chrono::hours{1}); + return "null"; + } + if (test_case == "child_basic") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { return durable::step([&] { return input; }); }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_named") { + const auto name = json_string_field(event, "name"); + const auto value = json_string_field(event, "value"); + const auto result = durable::run_in_child_context( + [&] { return durable::step([&] { return value; }); }, + durable::child_context_config{.name = name}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_sequential") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { + const auto first = durable::step([&] { return input; }); + return durable::step([&] { return first; }); + }, + durable::child_context_config{.name = "sequential-child"}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_error") { + (void)durable::run_in_child_context( + [] { + return durable::step( + []() -> std::string { + throw std::runtime_error{"child step failed"}; + }, + durable::step_config{ + .retry = durable::retry_strategy::none(), + }); + }, + durable::child_context_config{.name = "failing-child"}); + return "null"; + } + if (test_case == "child_error_caught") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + try { + (void)durable::run_in_child_context([] { + return durable::step( + []() -> std::string { + throw std::runtime_error{"expected child failure"}; + }, + durable::step_config{ + .retry = durable::retry_strategy::none(), + }); + }); + } catch (const std::exception&) { + } + const auto recovered = durable::step([&] { return input; }); + return durable::default_serdes{}.serialize( + recovered, {}); + } + if (test_case == "child_nested") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { + (void)durable::step([&] { return input; }); + return durable::run_in_child_context( + [&] { return durable::step([&] { return input; }); }, + durable::child_context_config{.name = "inner-child"}); + }, + durable::child_context_config{.name = "outer-child"}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_retry") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { + return durable::step( + [&](std::uint32_t attempt) -> std::string { + if (attempt == 1U) { + throw std::runtime_error{"retry child step"}; + } + return input; + }, + durable::step_config{ + .retry = durable::retry_strategy{ + .max_attempts = 2, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + }, + durable::child_context_config{.name = "retry-child"}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_retry_exhaustion") { + (void)durable::run_in_child_context( + [] { + return durable::step( + [](std::uint32_t) -> std::string { + throw std::runtime_error{"child retry exhausted"}; + }, + durable::step_config{ + .retry = durable::retry_strategy{ + .max_attempts = 2, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + }, + durable::child_context_config{ + .name = "retry-exhaustion-child"}); + return "null"; + } + if (test_case == "child_replay") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { return durable::step([&] { return input; }); }); + durable::wait(1s); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_step_wait") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { + (void)durable::step([&] { return input; }); + durable::wait(1s); + return input; + }, + durable::child_context_config{.name = "step-wait-child"}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_large_replay") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + (void)durable::run_in_child_context( + [&] { + emit_execution_log(input); + const auto small = durable::step([&] { return input; }); + std::string large; + large.reserve(300'000); + while (large.size() < 300'000U) { + large.append(small); + } + return large; + }, + durable::child_context_config{.name = "large-child"}); + durable::wait(1s); + return "null"; + } + if (test_case == "child_interrupted") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context([&] { + const auto marker = interrupted_marker_path( + durable::current_context().state().durable_execution_arn()); + return durable::step([&]() -> std::string { + std::ifstream existing{marker}; + if (!existing.good()) { + std::ofstream created{marker, std::ios::trunc}; + created << "interrupted"; + created.close(); + std::_Exit(1); + } + return input; + }); + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_wait_then_step") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto child_result = durable::run_in_child_context( + [&] { + durable::wait(1s); + return input; + }, + durable::child_context_config{.name = "wait-child"}); + const auto result = durable::step([&] { return child_result; }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_custom_serdes") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { return durable::step([&] { return input; }); }, + uppercase_serdes{}, + durable::child_context_config{.name = "serde-child"}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_direct_error") { + (void)durable::run_in_child_context( + []() -> std::string { + throw std::runtime_error{"direct child failure"}; + }, + durable::child_context_config{.name = "direct-error-child"}); + return "null"; + } + if (test_case == "child_null") { + (void)durable::run_in_child_context( + [] { return std::monostate{}; }, + durable::child_context_config{.name = "null-child"}); + return "null"; + } + if (test_case == "child_logger_replay") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { + emit_execution_log(input); + return input; + }, + durable::child_context_config{.name = "logger-child"}); + durable::wait(1s); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "child_mixed") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto child_result = durable::run_in_child_context( + [&] { + (void)durable::step([&] { return input; }); + durable::wait(1s); + return input; + }, + durable::child_context_config{.name = "mixed-child"}); + const auto result = durable::step([&] { return child_result; }); + durable::wait(1s); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "callback_basic") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + auto callback = durable::create_callback( + durable::callback_config{.name = name}); + return callback.result().value_or("null"); + } + if (test_case == "callback_named") { + auto callback = durable::create_callback( + durable::callback_config{.name = "approval"}); + return callback.result().value_or("null"); + } + if (test_case == "callback_timeout" || + test_case == "callback_heartbeat_timeout" || + test_case == "callback_heartbeat_success" || + test_case == "callback_failure") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + durable::callback_config config{.name = name}; + if (test_case == "callback_timeout") { + config.timeout = 5s; + } else if (test_case == "callback_heartbeat_timeout") { + config.heartbeat_timeout = 5s; + } else if (test_case == "callback_heartbeat_success") { + config.heartbeat_timeout = 10s; + } + auto callback = durable::create_callback(std::move(config)); + return callback.result().value_or("null"); + } + if (test_case == "callback_step_failure" || + test_case == "callback_step_timeout") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + auto callback = durable::create_callback( + durable::callback_config{ + .name = name, + .timeout = + test_case == "callback_step_timeout" ? 5s : 0s, + }); + (void)durable::step([] { return std::string{"step-complete"}; }); + return callback.result().value_or("null"); + } + if (test_case == "callback_wait_success" || + test_case == "callback_wait_failure" || + test_case == "callback_wait_timeout") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + auto callback = durable::create_callback( + durable::callback_config{ + .name = name, + .timeout = + test_case == "callback_wait_timeout" ? 3s : 0s, + }); + durable::wait( + test_case == "callback_wait_timeout" ? 6s : 5s); + return callback.result().value_or("null"); + } + if (test_case == "callback_then_wait") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + auto callback = durable::create_callback( + durable::callback_config{.name = name}); + const auto result = callback.result().value_or("null"); + durable::wait(2s); + return result; + } + if (test_case == "callback_failure_caught" || + test_case == "callback_timeout_caught") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + auto callback = durable::create_callback( + durable::callback_config{ + .name = name, + .timeout = + test_case == "callback_timeout_caught" ? 3s : 0s, + }); + std::string message; + try { + (void)callback.result(); + } catch (const durable::callback_error& error) { + message = error.what(); + } + durable::wait(2s); + return durable::default_serdes{}.serialize(message, {}); + } + if (test_case == "callback_custom_object") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + auto callback = durable::create_callback( + durable::callback_config{.name = name}, + callback_object_serdes{}); + return callback.result().value_or("null"); + } + if (test_case == "callback_custom_number") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + auto callback = durable::create_callback( + durable::callback_config{.name = name}); + const int count = callback.result().value_or(0); + return std::string{"{\"count\":"} + std::to_string(count) + + ",\"doubled\":" + std::to_string(count * 2) + "}"; + } + if (test_case == "callback_two_sequential") { + const auto names = parse_string_array(event); + auto first = durable::create_callback( + durable::callback_config{.name = names.at(0)}); + const auto first_result = first.result().value_or("null"); + auto second = durable::create_callback( + durable::callback_config{.name = names.at(1)}); + const auto second_result = second.result().value_or("null"); + return serialize_raw_array({first_result, second_result}); + } + if (test_case == "callback_two_ordered" || + test_case == "callback_two_reverse") { + const auto names = parse_string_array(event); + auto first = durable::create_callback( + durable::callback_config{.name = names.at(0)}); + auto second = durable::create_callback( + durable::callback_config{.name = names.at(1)}); + std::string first_result; + std::string second_result; + if (test_case == "callback_two_ordered") { + first_result = first.result().value_or("null"); + second_result = second.result().value_or("null"); + } else { + second_result = second.result().value_or("null"); + first_result = first.result().value_or("null"); + } + return serialize_raw_array({first_result, second_result}); + } + if (test_case == "invoke_basic") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::invoke( + env.target_function_name, input); + return result + ? durable::default_serdes{}.serialize( + *result, {}) + : "null"; + } + if (test_case == "invoke_named") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto name = json_string_field(event, "name"); + const auto payload = json_string_field(event, "payload"); + const auto result = durable::invoke( + env.target_function_name, payload, + durable::invoke_config{.name = name}); + return result + ? durable::default_serdes{}.serialize( + *result, {}) + : "null"; + } + if (test_case == "invoke_complex") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto result = durable::invoke( + env.target_function_name, std::string{event}, {}, + durable::passthrough_serdes{}, durable::passthrough_serdes{}); + return result.value_or("null"); + } + if (test_case == "invoke_null") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto result = durable::invoke( + env.target_function_name, std::monostate{}); + (void)result; + return "null"; + } + if (test_case == "invoke_failure" || + test_case == "invoke_failure_caught" || + test_case == "invoke_replay_failure") { + if (env.error_function_name.empty()) { + throw std::invalid_argument("ERROR_FUNCTION_NAME is required"); + } + bool caught = false; + try { + (void)durable::invoke( + env.error_function_name, std::monostate{}); + } catch (const durable::callable_error&) { + caught = true; + if (test_case == "invoke_failure") { + throw; + } + } + if (test_case == "invoke_replay_failure" && caught) { + durable::wait(1s); + } + return durable::default_serdes{}.serialize( + caught ? "fallback" : "unexpected", {}); + } + if (test_case == "invoke_large") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const std::string payload(512U * 1024U, 'x'); + (void)durable::invoke( + env.target_function_name, payload); + return "null"; + } + if (test_case == "invoke_tenant") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto tenant_id = json_string_field(event, "tenantId"); + const auto payload = json_string_field(event, "payload"); + const auto result = durable::invoke( + env.target_function_name, payload, + durable::invoke_config{.tenant_id = tenant_id}); + return result + ? durable::default_serdes{}.serialize( + *result, {}) + : "null"; + } + if (test_case == "invoke_replay") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto result = durable::invoke( + env.target_function_name, std::string{"cached"}); + durable::wait(1s); + return result + ? durable::default_serdes{}.serialize( + *result, {}) + : "null"; + } + if (test_case == "step_then_invoke") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto step_result = + durable::step([] { return std::string{"step-result"}; }); + const auto result = durable::invoke( + env.target_function_name, step_result); + return result + ? durable::default_serdes{}.serialize( + *result, {}) + : "null"; + } + if (test_case == "invoke_then_step") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto invoked = durable::invoke( + env.target_function_name, std::string{"invoke-result"}); + const auto result = durable::step( + [&] { return invoked.value_or("missing"); }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "invoke_in_child") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto result = durable::run_in_child_context([&] { + return durable::invoke( + env.target_function_name, std::string{"child-invoke"}) + .value_or("null"); + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "invoke_sequential") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto first = durable::invoke( + env.target_function_name, std::string{"first"}); + const auto second = durable::invoke( + env.target_function_name, first.value_or("missing")); + return second + ? durable::default_serdes{}.serialize( + *second, {}) + : "null"; + } + if (test_case == "invoke_payload_serdes") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto payload = json_string_field(event, "data"); + const auto result = durable::invoke( + env.target_function_name, payload, {}, uppercase_payload_serdes{}, + durable::passthrough_serdes{}); + return result.value_or("null"); + } + if (test_case == "invoke_result_serdes") { + if (env.target_function_name.empty()) { + throw std::invalid_argument("TARGET_FUNCTION_NAME is required"); + } + const auto payload = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::invoke( + env.target_function_name, payload, {}, + durable::default_serdes{}, uppercase_json_serdes{}); + return result + ? durable::default_serdes{}.serialize( + *result, {}) + : "null"; + } + if (test_case == "wait_for_condition_basic") { + const int threshold = + durable::default_serdes{}.deserialize(event, {}); + const int result = durable::wait_for_condition( + [](const std::optional& state) { + return state.value_or(0) + 1; + }, + 0, + [threshold](const int& value, std::uint32_t) { + return value >= threshold + ? std::optional{} + : std::optional{1s}; + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "wait_for_condition_immediate") { + const int initial = + durable::default_serdes{}.deserialize(event, {}); + const int result = durable::wait_for_condition( + [](const std::optional& state) { + return state.value_or(5); + }, + initial, + [](const int& value, std::uint32_t) { + return value >= 5 + ? std::optional{} + : std::optional{1s}; + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "wait_for_condition_named" || + test_case == "wait_for_condition_initial" || + test_case == "wait_for_condition_fixed") { + const int threshold = + durable::default_serdes{}.deserialize(event, {}); + const int initial = + test_case == "wait_for_condition_initial" ? 5 : 0; + const int result = durable::wait_for_condition( + [](const std::optional& state) { + return state.value_or(0) + 1; + }, + initial, + [threshold, test_case]( + const int& value, std::uint32_t) { + if (value >= threshold) { + return std::optional{}; + } + return std::optional{ + test_case == "wait_for_condition_fixed" ? 2s : 1s}; + }, + durable::wait_for_condition_config{ + .name = + test_case == "wait_for_condition_named" + ? std::optional{"poll-status"} + : std::nullopt, + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "wait_for_condition_exhausted") { + (void)durable::wait_for_condition( + [](const std::optional& state) { + return state.value_or(0) + 1; + }, + 0, + [](const int&, std::uint32_t attempt) + -> std::optional { + if (attempt >= 3U) { + throw durable::wait_for_condition_error{ + "maximum attempts exceeded"}; + } + return 1s; + }); + return "null"; + } + if (test_case == "wait_for_condition_error" || + test_case == "wait_for_condition_error_caught") { + try { + (void)durable::wait_for_condition( + []() -> int { + throw std::runtime_error{"condition check failed"}; + }, + std::nullopt, + [](const int&, std::uint32_t) { + return std::optional{}; + }); + } catch (const std::runtime_error&) { + if (test_case == "wait_for_condition_error") { + throw; + } + return R"("recovered")"; + } + return "null"; + } + if (test_case == "wait_for_condition_object") { + const auto result = + durable::wait_for_condition( + [](const std::optional& current) { + auto next = current.value_or(condition_state{ + .status = "PENDING", + .attempts = 0, + }); + ++next.attempts; + if (next.attempts >= 2) { + next.status = "DONE"; + } + return next; + }, + condition_state{ + .status = "PENDING", + .attempts = 0, + }, + [](const condition_state& state, std::uint32_t) { + return state.status == "DONE" + ? std::optional{} + : std::optional{1s}; + }, + {}, + condition_state_serdes{}); + return condition_state_serdes{}.serialize(result, {}); + } + if (test_case == "wait_for_condition_null") { + (void)durable::wait_for_condition( + [] { return std::monostate{}; }, + std::nullopt, + [](const std::monostate&, std::uint32_t) { + return std::optional{}; + }); + return "null"; + } + if (test_case == "wait_for_condition_serdes") { + const auto result = durable::wait_for_condition( + [](const std::optional& current) { + return current.value_or("") + "x"; + }, + std::string{}, + [](const std::string& value, std::uint32_t) { + return value.size() >= 2U + ? std::optional{} + : std::optional{1s}; + }, + {}, + wrapped_prefix_serdes{}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "wait_for_condition_then_step") { + const int threshold = + durable::default_serdes{}.deserialize(event, {}); + const int polled = durable::wait_for_condition( + [](const std::optional& current) { + return current.value_or(0) + 1; + }, + 0, + [threshold](const int& value, std::uint32_t) { + return value >= threshold + ? std::optional{} + : std::optional{1s}; + }); + const int result = durable::step([&] { return polled * 10; }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "wait_for_condition_sequential") { + const int first = durable::wait_for_condition( + [](const std::optional& current) { + return current.value_or(0) + 1; + }, + 0, + [](const int& value, std::uint32_t) { + return value >= 2 + ? std::optional{} + : std::optional{1s}; + }); + const int second = durable::wait_for_condition( + [](const std::optional& current) { + return current.value_or(0) + 1; + }, + first, + [](const int& value, std::uint32_t) { + return value >= 4 + ? std::optional{} + : std::optional{1s}; + }); + return durable::default_serdes{}.serialize(second, {}); + } + if (test_case == "wait_for_callback_basic") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + return durable::wait_for_callback( + [](std::string_view) {}, + durable::wait_for_callback_config{.name = name}) + .value_or("null"); + } + if (test_case == "wait_for_callback_named" || + test_case == "wait_for_callback_anonymous") { + const auto name = + test_case == "wait_for_callback_named" + ? std::optional{"approval"} + : std::nullopt; + return durable::wait_for_callback( + [](std::string_view) {}, + durable::wait_for_callback_config{.name = name}) + .value_or("null"); + } + if (test_case == "wait_for_callback_failure" || + test_case == "wait_for_callback_timeout" || + test_case == "wait_for_callback_heartbeat_timeout" || + test_case == "wait_for_callback_heartbeat_success" || + test_case == "wait_for_callback_empty") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + durable::wait_for_callback_config config{.name = name}; + if (test_case == "wait_for_callback_timeout") { + config.timeout = 3s; + } else if ( + test_case == "wait_for_callback_heartbeat_timeout") { + config.heartbeat_timeout = 5s; + } else if ( + test_case == "wait_for_callback_heartbeat_success") { + config.heartbeat_timeout = 10s; + } + return durable::wait_for_callback( + [](std::string_view) {}, std::move(config)) + .value_or("null"); + } + if (test_case == "wait_for_callback_failure_caught" || + test_case == "wait_for_callback_timeout_caught") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + try { + (void)durable::wait_for_callback( + [](std::string_view) {}, + durable::wait_for_callback_config{ + .name = name, + .timeout = + test_case == "wait_for_callback_timeout_caught" + ? 3s + : 0s, + }); + } catch (const durable::callback_error&) { + return test_case == "wait_for_callback_timeout_caught" + ? R"("timed-out-handled")" + : R"("recovered")"; + } + return "null"; + } + if (test_case == "wait_for_callback_submitter_exhausted") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + (void)durable::wait_for_callback( + [](std::string_view) { + throw std::runtime_error{"submitter failed"}; + }, + durable::wait_for_callback_config{ + .name = name, + .submitter_retry = durable::retry_strategy{ + .max_attempts = 2, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + return "null"; + } + if (test_case == "wait_for_callback_child") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context( + [&] { + return durable::wait_for_callback( + [](std::string_view) {}, + durable::wait_for_callback_config{.name = name}) + .value_or("null"); + }, + durable::passthrough_serdes{}, + durable::child_context_config{.name = "wrapper"}); + return result; + } + if (test_case == "wait_for_callback_sequential") { + (void)durable::wait_for_callback( + [](std::string_view) {}, + durable::wait_for_callback_config{.name = "first"}); + return durable::wait_for_callback( + [](std::string_view) {}, + durable::wait_for_callback_config{.name = "second"}) + .value_or("null"); + } + if (test_case == "wait_for_callback_mixed") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + durable::wait(1s); + (void)durable::step( + [] { return std::string{"top-level-step"}; }); + return durable::wait_for_callback( + [](std::string_view) {}, + durable::wait_for_callback_config{.name = name}) + .value_or("null"); + } + if (test_case == "wait_for_callback_object") { + const auto name = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::wait_for_callback( + [](std::string_view) {}, + durable::wait_for_callback_config{.name = name}, + status_field_serdes{}); + return durable::default_serdes{}.serialize( + result.value_or(""), {}); + } + if (test_case == "plugin_invocation_lifecycle" || + test_case == "plugin_operation_lifecycle" || + test_case == "plugin_error_isolation" || + test_case == "plugin_multiple" || + test_case == "plugin_operation_change" || + test_case == "plugin_faulty_healthy") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto execution_arn = + durable::current_context().state().durable_execution_arn(); + const auto result = durable::step([&] { + if (test_case == "plugin_invocation_lifecycle") { + emit_execution_log( + "Greeting step running for: " + input, + execution_arn); + } + return "Hello, " + input + "!"; + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "plugin_attempt_hooks") { + const auto result = durable::step( + [](std::uint32_t attempt) -> std::string { + if (attempt < 2U) { + throw std::runtime_error{ + "Attempt " + std::to_string(attempt) + " failed"}; + } + return "Operation succeeded"; + }, + durable::step_config{ + .retry = durable::retry_strategy{ + .max_attempts = 3, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "plugin_first_invocation" || + test_case == "plugin_external_update" || + test_case == "plugin_wait_lifecycle" || + test_case == "plugin_suspension") { + durable::wait(2s); + return R"("Wait completed")"; + } + if (test_case == "plugin_terminal_failure") { + (void)durable::step( + []() -> std::string { + throw std::runtime_error{"Something went wrong"}; + }, + durable::step_config{ + .retry = durable::retry_strategy::none(), + }); + return "null"; + } + if (test_case == "plugin_parent_linkage") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + const auto result = durable::run_in_child_context([&] { + return durable::step([&] { return "Hello, " + input + "!"; }); + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "plugin_parallel_functions") { + auto result = durable::parallel( + std::tuple{ + [] { return std::string{"task-1"}; }, + [] { return std::string{"task-2"}; }, + }, + durable::parallel_config{ + .name = "parallel", + .max_concurrency = 1, + }); + return serialize_string_array(result.results()); + } + if (test_case == "plugin_replay_flags") { + (void)durable::step([] { return std::string{"step-a"}; }); + (void)durable::step( + [](std::uint32_t attempt) -> std::string { + if (attempt < 2U) { + throw std::runtime_error{"step B first attempt failed"}; + } + return "step-b"; + }, + durable::step_config{ + .retry = durable::retry_strategy{ + .max_attempts = 2, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + return R"("Operation succeeded")"; + } + if (test_case == "plugin_terminal_payloads") { + (void)durable::step([] { return std::string{"task-a"}; }); + (void)durable::step( + []() -> std::string { + throw std::runtime_error{"boom"}; + }, + durable::step_config{ + .retry = durable::retry_strategy::none(), + }); + return "null"; + } + if (test_case == "plugin_retry_exhaustion") { + (void)durable::step( + []() -> std::string { + throw std::runtime_error{"always fails"}; + }, + durable::step_config{ + .retry = durable::retry_strategy{ + .max_attempts = 2, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + return "null"; + } + if (test_case == "plugin_wait_replay") { + auto result = durable::parallel( + std::tuple{ + [] { + durable::wait(2s, "short"); + return std::string{"short-done"}; + }, + [] { + durable::wait(8s, "long"); + return std::string{"long-done"}; + }, + }, + durable::parallel_config{ + .name = "waits", + .max_concurrency = 2, + }); + return serialize_string_array(result.results()); + } + if (test_case == "plugin_invocation_shape") { + const auto input = + durable::default_serdes{}.deserialize(event, {}); + durable::wait(2s); + return durable::default_serdes{}.serialize( + "done-" + input, {}); + } + if (test_case == "plugin_operation_shape" || + test_case == "plugin_change_shape") { + const auto result = durable::step( + [] { return std::string{"task-a"}; }, + durable::step_config{.name = "greet"}); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "plugin_attempt_shape") { + const auto result = durable::step( + [](std::uint32_t attempt) -> std::string { + if (attempt < 2U) { + throw std::runtime_error{"first attempt failed"}; + } + return "ok"; + }, + durable::step_config{ + .name = "flaky", + .retry = durable::retry_strategy{ + .max_attempts = 2, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + return durable::default_serdes{}.serialize(result, {}); + } + if (test_case == "plugin_context_shape") { + auto result = durable::parallel( + std::tuple{ + [] { + (void)durable::step( + [] { return std::string{"x"}; }, + durable::step_config{.name = "inner"}); + durable::wait(2s); + return std::string{"a-done"}; + }, + [] { return std::string{"b-done"}; }, + }, + durable::parallel_config{ + .name = "ctx", + .max_concurrency = 1, + .branch_namer = + [](std::size_t index) { + return index == 0U ? "branch-a" : "branch-b"; + }, + }); + return serialize_string_array(result.results()); + } + if (test_case == "parallel_basic") { + auto result = durable::parallel( + std::tuple{ + [] { + return durable::step( + [] { return std::string{"task-1"}; }); + }, + [] { + return durable::step( + [] { return std::string{"task-2"}; }); + }}, + durable::parallel_config{ + .name = "parallel", + .max_concurrency = 1, + }); + return serialize_string_array(result.results()); + } + if (test_case == "parallel_heterogeneous") { + auto result = durable::parallel( + std::tuple{ + [] { return std::string{R"("hello")"}; }, + [] { return std::string{"42"}; }, + [] { return std::string{R"({"k":"v"})"}; }, + }, + durable::parallel_config{ + .max_concurrency = 1, + }, + durable::passthrough_serdes{}); + return serialize_raw_array(result.results()); + } + if (test_case == "parallel_no_name" || + test_case == "parallel_named" || + test_case == "parallel_concurrent") { + std::vector> branches; + if (test_case == "parallel_no_name") { + branches = { + [] { return std::string{"alpha"}; }, + [] { return std::string{"beta"}; }, + }; + } else if (test_case == "parallel_named") { + branches = { + [] { return std::string{"one"}; }, + [] { return std::string{"two"}; }, + }; + } else { + branches = { + [] { return std::string{"r0"}; }, + [] { return std::string{"r1"}; }, + [] { return std::string{"r2"}; }, + }; + } + durable::parallel_config config{ + .name = test_case == "parallel_no_name" + ? std::nullopt + : std::optional{ + test_case == "parallel_named" ? "named" + : "concurrent"}, + .max_concurrency = + test_case == "parallel_concurrent" ? 2U : 1U, + }; + if (test_case == "parallel_named") { + config.branch_namer = [](std::size_t index) { + return index == 0U ? "first" : "second"; + }; + } + return serialize_string_array( + durable::parallel(branches, std::move(config)).results()); + } + if (test_case == "parallel_empty") { + const std::vector> branches; + return serialize_string_array( + durable::parallel( + branches, durable::parallel_config{.name = "empty"}) + .results()); + } + if (test_case == "parallel_failfast" || + test_case == "parallel_throw" || + test_case == "parallel_tolerated" || + test_case == "parallel_tolerated_exceeded" || + test_case == "parallel_pct_exceeded" || + test_case == "parallel_all_fail" || + test_case == "parallel_min_not_reached" || + test_case == "parallel_combined" || + test_case == "parallel_accessors" || + test_case == "parallel_pct_boundary") { + std::vector> branches; + durable::parallel_config config{.max_concurrency = 1}; + if (test_case == "parallel_failfast") { + config.name = "failfast"; + config.completion = + durable::completion_config::all_successful(); + branches = { + [] { return std::string{"ok"}; }, + []() -> std::string { throw std::runtime_error{"fail"}; }, + [] { return std::string{"unused"}; }, + }; + } else if (test_case == "parallel_throw") { + config.name = "throwing"; + config.completion = + durable::completion_config::all_successful(); + branches = { + []() -> std::string { throw std::runtime_error{"fail"}; }, + [] { return std::string{"unused"}; }, + }; + } else if (test_case == "parallel_tolerated" || + test_case == "parallel_accessors") { + config.name = + test_case == "parallel_tolerated" ? "tolerated" : "accessors"; + config.completion = + durable::completion_config::thresholds(std::nullopt, 1U); + branches = { + [] { return std::string{"ok-0"}; }, + []() -> std::string { throw std::runtime_error{"fail"}; }, + [] { return std::string{"ok-2"}; }, + }; + } else if (test_case == "parallel_tolerated_exceeded") { + config.name = "tolerated-exceeded"; + config.completion = + durable::completion_config::thresholds(std::nullopt, 1U); + branches = { + []() -> std::string { throw std::runtime_error{"fail-0"}; }, + []() -> std::string { throw std::runtime_error{"fail-1"}; }, + [] { return std::string{"unused"}; }, + }; + } else if (test_case == "parallel_pct_exceeded") { + config.name = "tolerated-pct"; + config.completion = durable::completion_config::thresholds( + std::nullopt, std::nullopt, 25.0); + branches = { + []() -> std::string { throw std::runtime_error{"fail-0"}; }, + []() -> std::string { throw std::runtime_error{"fail-1"}; }, + [] { return std::string{"unused-2"}; }, + [] { return std::string{"unused-3"}; }, + }; + } else if (test_case == "parallel_all_fail") { + config.name = "all-fail"; + config.completion = + durable::completion_config::thresholds(std::nullopt, 3U); + branches = { + []() -> std::string { throw std::runtime_error{"fail-0"}; }, + []() -> std::string { throw std::runtime_error{"fail-1"}; }, + []() -> std::string { throw std::runtime_error{"fail-2"}; }, + }; + } else if (test_case == "parallel_min_not_reached") { + config.name = "min-not-reached"; + config.completion = + durable::completion_config::thresholds(3U); + branches = { + [] { return std::string{"ok-0"}; }, + []() -> std::string { throw std::runtime_error{"fail"}; }, + [] { return std::string{"ok-2"}; }, + }; + } else if (test_case == "parallel_combined") { + config.name = "combined"; + config.completion = + durable::completion_config::thresholds(3U, 1U); + branches = { + []() -> std::string { throw std::runtime_error{"fail-0"}; }, + []() -> std::string { throw std::runtime_error{"fail-1"}; }, + [] { return std::string{"unused-2"}; }, + [] { return std::string{"unused-3"}; }, + }; + } else { + config.name = "pct-boundary"; + config.completion = durable::completion_config::thresholds( + std::nullopt, std::nullopt, 25.0); + branches = { + []() -> std::string { throw std::runtime_error{"fail"}; }, + [] { return std::string{"ok-1"}; }, + [] { return std::string{"ok-2"}; }, + [] { return std::string{"ok-3"}; }, + }; + } + auto result = durable::parallel(branches, std::move(config)); + if (test_case == "parallel_throw") { + result.throw_if_error(); + return "null"; + } + if (test_case == "parallel_accessors") { + return batch_projection( + result, + {"hasFailure", "successCount", "failureCount", "errorCount"}); + } + const bool include_status = + test_case == "parallel_failfast" || + test_case == "parallel_tolerated" || + test_case == "parallel_all_fail" || + test_case == "parallel_min_not_reached" || + test_case == "parallel_pct_boundary"; + if (include_status) { + return batch_projection( + result, + {"completionReason", "status", "successCount", + "failureCount", "totalCount"}); + } + return batch_projection( + result, + {"completionReason", "successCount", "failureCount", "totalCount"}); + } + if (test_case == "parallel_min_successful") { + const std::vector> branches{ + [] { return std::string{"a"}; }, + [] { return std::string{"b"}; }, + [] { return std::string{"c"}; }, + [] { return std::string{"d"}; }, + }; + const auto result = durable::parallel( + branches, + durable::parallel_config{ + .name = "min-successful", + .max_concurrency = 1, + .completion = + durable::completion_config::thresholds(2U), + }); + return batch_projection( + result, {"completionReason", "successCount", "totalCount"}); + } + if (test_case == "parallel_flat") { + const std::vector> branches{ + [] { return durable::step([] { return std::string{"fa"}; }); }, + [] { return durable::step([] { return std::string{"fb"}; }); }, + }; + return serialize_string_array( + durable::parallel( + branches, + durable::parallel_config{ + .name = "flat", + .max_concurrency = 1, + .nesting = durable::nesting_type::flat, + }) + .results()); + } + if (test_case == "parallel_replay") { + const std::vector> branches{ + [] { return durable::step([] { return std::string{"b0"}; }); }, + [] { + durable::wait(2s); + return std::string{"b1"}; + }, + }; + return serialize_string_array( + durable::parallel( + branches, + durable::parallel_config{ + .name = "replay", + .max_concurrency = 1, + }) + .results()); + } + if (test_case == "parallel_serde") { + const std::vector> branches{ + [] { return std::string{"x"}; }, + [] { return std::string{"y"}; }, + }; + return serialize_string_array( + durable::parallel( + branches, + durable::parallel_config{ + .name = "serde", + .max_concurrency = 1, + }, + wrapped_object_serdes{}) + .results()); + } + if (test_case == "parallel_bad_concurrency") { + const std::vector> branches{ + [] { return std::string{"a"}; }, + [] { return std::string{"b"}; }, + }; + (void)durable::parallel( + branches, + durable::parallel_config{ + .name = "bad-concurrency", + .max_concurrency = 0, + }); + return "null"; + } + if (test_case == "parallel_nested") { + const std::vector> outer{ + [] { + const std::vector> inner{ + [] { + return durable::step( + [] { return std::string{"i1"}; }); + }, + [] { + return durable::step( + [] { return std::string{"i2"}; }); + }, + }; + return serialize_string_array( + durable::parallel( + inner, + durable::parallel_config{ + .name = "inner", + .max_concurrency = 1, + }) + .results()); + }, + }; + const auto result = durable::parallel( + outer, + durable::parallel_config{ + .name = "outer", + .max_concurrency = 1, + }, + durable::passthrough_serdes{}); + return serialize_raw_array(result.results()); + } + if (test_case == "map_basic") { + const auto items = parse_string_array(event); + auto result = durable::map( + [](const std::string& item) { + return durable::step( + [&] { return std::string{"Hello, "} + item + "!"; }); + }, + items, + durable::map_config{ + .name = "map", + .max_concurrency = 1, + }); + return serialize_string_array(result.results()); + } + if (test_case == "map_items_only" || + test_case == "map_indexed" || + test_case == "map_named_items") { + const auto items = parse_int_array(event); + durable::map_config config{ + .name = + test_case == "map_items_only" + ? std::nullopt + : std::optional{ + test_case == "map_indexed" ? "indexed" + : "named-items"}, + .max_concurrency = 1, + }; + if (test_case == "map_named_items") { + config.item_namer = [&items](std::size_t index) { + return "item-" + std::to_string(items[index]); + }; + } + auto result = durable::map( + [&](const int& item, std::size_t index) { + if (test_case == "map_items_only") return item * 2; + if (test_case == "map_indexed") { + return item + static_cast(index); + } + return item * 10; + }, + items, std::move(config)); + return serialize_int_array(result.results()); + } + if (test_case == "map_empty") { + const std::vector items; + return serialize_int_array( + durable::map( + [](const int& value) { return value; }, items, + durable::map_config{.name = "empty"}) + .results()); + } + if (test_case == "map_failfast" || + test_case == "map_throw" || + test_case == "map_min_successful" || + test_case == "map_tolerated" || + test_case == "map_tolerated_exceeded" || + test_case == "map_pct_exceeded" || + test_case == "map_fail_then_wait") { + std::vector items; + durable::map_config config{.max_concurrency = 1}; + if (test_case == "map_failfast") { + config.name = "failfast"; + config.completion = + durable::completion_config::all_successful(); + items = {0, 1, 2}; + } else if (test_case == "map_throw") { + config.name = "throwing"; + config.completion = + durable::completion_config::all_successful(); + items = {0, 1}; + } else if (test_case == "map_min_successful") { + config.name = "min-successful"; + config.completion = + durable::completion_config::thresholds(2U); + items = {0, 1, 2, 3}; + } else if (test_case == "map_tolerated" || + test_case == "map_fail_then_wait") { + config.name = + test_case == "map_tolerated" ? "tolerated" : "fail-then-wait"; + config.completion = + durable::completion_config::thresholds(std::nullopt, 1U); + items = {0, 1, 2}; + if (test_case == "map_fail_then_wait") items = {0, 1}; + } else if (test_case == "map_tolerated_exceeded") { + config.name = "tolerated-exceeded"; + config.completion = + durable::completion_config::thresholds(std::nullopt, 1U); + items = {0, 1, 2}; + } else { + config.name = "tolerated-pct"; + config.completion = durable::completion_config::thresholds( + std::nullopt, std::nullopt, 25.0); + items = {0, 1, 2, 3}; + } + auto result = durable::map( + [&](const int& item) -> std::string { + if ((test_case == "map_failfast" && item == 1) || + (test_case == "map_throw" && item == 0) || + (test_case == "map_tolerated" && item == 1) || + (test_case == "map_fail_then_wait" && item == 1) || + ((test_case == "map_tolerated_exceeded" || + test_case == "map_pct_exceeded") && + item < 2)) { + throw std::runtime_error{"map failure"}; + } + if ((test_case == "map_failfast" || + test_case == "map_fail_then_wait") && + item == 0) { + return "ok"; + } + return "r" + std::to_string(item); + }, + items, std::move(config)); + if (test_case == "map_throw") { + result.throw_if_error(); + return "null"; + } + if (test_case == "map_fail_then_wait") { + durable::wait(1s); + return batch_projection( + result, + {"completionReason", "status", "successCount", + "failureCount", "totalCount"}); + } + if (test_case == "map_min_successful") { + return batch_projection( + result, {"completionReason", "successCount", "totalCount"}); + } + const bool include_status = + test_case == "map_failfast" || test_case == "map_tolerated"; + return include_status + ? batch_projection( + result, + {"completionReason", "status", "successCount", + "failureCount", "totalCount"}) + : batch_projection( + result, + {"completionReason", "successCount", + "failureCount", "totalCount"}); + } + if (test_case == "map_concurrent") { + const std::array items{0, 1, 2}; + auto result = durable::map( + [](const int& item) { + return "r" + std::to_string(item); + }, + items, + durable::map_config{ + .name = "concurrent", + .max_concurrency = 2, + }); + return serialize_string_array(result.results()); + } + if (test_case == "map_flat") { + const std::array items{std::string{"fa"}, std::string{"fb"}}; + auto result = durable::map( + [](const std::string& item) { + return durable::step([&] { return item; }); + }, + items, + durable::map_config{ + .name = "flat", + .max_concurrency = 1, + .nesting = durable::nesting_type::flat, + }); + return serialize_string_array(result.results()); + } + if (test_case == "map_serde") { + const std::array items{std::string{"x"}, std::string{"y"}}; + auto result = durable::map( + [](const std::string& item) { + std::string value = item; + value[0] = static_cast( + std::toupper(static_cast(value[0]))); + return value; + }, + items, + durable::map_config{ + .name = "serdes", + .max_concurrency = 1, + }, + wrapped_prefix_serdes{}); + return serialize_string_array(result.results()); + } + if (test_case == "map_suspend") { + const std::array items{std::string{"r0"}, std::string{"r1"}}; + auto result = durable::map( + [](const std::string& item, std::size_t index) { + if (index == 1U) durable::wait(1s); + return durable::step([&] { return item; }); + }, + items, + durable::map_config{ + .name = "suspend", + .max_concurrency = 1, + }); + return serialize_string_array(result.results()); + } + if (test_case == "map_large") { + const std::array items{0, 1, 2, 3}; + auto result = durable::map( + [](const int&) { return std::string(70'000, 'x'); }, + items, + durable::map_config{ + .name = "large", + .max_concurrency = 1, + }); + return batch_projection(result, {"successCount", "totalCount"}); + } + if (test_case == "map_then_wait") { + const std::array items{std::string{"a"}, std::string{"b"}}; + auto result = durable::map( + [](const std::string& item) { + std::string value = item; + value[0] = static_cast( + std::toupper(static_cast(value[0]))); + return value; + }, + items, + durable::map_config{ + .name = "then-wait", + .max_concurrency = 1, + }); + durable::wait(1s); + return serialize_string_array(result.results()); + } + if (test_case == "map_operation_serde" || + test_case == "map_operation_serde_replay") { + const std::array items{std::string{"x"}, std::string{"y"}}; + auto result = durable::map( + [](const std::string& item) { + std::string value = item; + value[0] = static_cast( + std::toupper(static_cast(value[0]))); + return value; + }, + items, + durable::map_config{ + .name = test_case == "map_operation_serde" + ? "op-serde" + : "op-serde-replay", + .max_concurrency = 1, + }, + durable::default_serdes{}, + map_operation_serdes{}); + if (test_case == "map_operation_serde_replay") { + durable::wait(1s); + } + return serialize_string_array(result.results()); + } + throw std::invalid_argument( + "Unknown conformance case: " + std::string{test_case}); +} + +} // namespace durable_conformance diff --git a/conformance/local_tests.cpp b/conformance/local_tests.cpp new file mode 100644 index 0000000..e6609ea --- /dev/null +++ b/conformance/local_tests.cpp @@ -0,0 +1,704 @@ +#include +#include +#include +#include +#include + +#include "aws/durable_execution/local_runner.hpp" +#include "conformance/handlers.hpp" +#include "conformance/plugins.hpp" + +namespace durable = aws::durable_execution; + +namespace { + +int failures = 0; + +#define CHECK(expression) \ + do { \ + if (!(expression)) { \ + std::cerr << __FILE__ << ':' << __LINE__ \ + << ": CHECK failed: " << #expression << '\n'; \ + ++failures; \ + } \ + } while (false) + +durable::local_runner runner_for( + std::string test_case, std::string input, + durable_conformance::environment environment = {}, + bool auto_advance_callback_timeouts = false) { + auto run_configuration = + durable_conformance::plugins_for(test_case); + return durable::make_local_runner( + [test_case = std::move(test_case), + environment = std::move(environment)]( + std::string_view event) { + return durable_conformance::dispatch( + test_case, event, environment); + }, + durable::local_runner_options{ + .input_json = std::move(input), + .execution_timeout = std::chrono::seconds{7200}, + .auto_advance_callback_timeouts = + auto_advance_callback_timeouts, + }, + durable::passthrough_serdes{}, + std::move(run_configuration)); +} + +void run_sync_cases() { + auto step = runner_for("step_basic", R"("World")").run(); + CHECK(step.status() == durable::local_run_status::succeeded); + CHECK(step.output().result == R"("Hello, World!")"); + + const std::vector> + successful_step_cases{ + {"step_named", R"("World")", R"("Hello, World!")"}, + {"step_sequential", "null", R"("first_second")"}, + {"step_complex", R"({"name":"Alice","tags":["admin","active"]})", + R"({"user":{"name":"Alice","tags":["admin","active"]},"count":2})"}, + {"step_null", "null", "null"}, + {"step_custom_serdes", R"("hello world")", + R"("HELLO WORLD")"}, + {"step_logger", R"("World")", R"("Hello, World!")"}, + {"step_then_wait", "null", R"("computed")"}, + {"step_replay_skips", "null", R"("cached_value")"}, + {"step_replay_rethrows", "null", "null"}, + {"step_retry_once", "null", R"("Operation succeeded")"}, + {"step_default_retry", "null", R"("succeeded")"}, + {"step_custom_retry", "null", R"("succeeded")"}, + {"step_retry_specific", "null", R"("succeeded")"}, + {"step_error_caught", "null", R"("fallback_result")"}, + }; + for (const auto& [test_case, input, expected] : + successful_step_cases) { + const auto result = runner_for(test_case, input).run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == expected); + } + for (const auto test_case : { + "step_retry_exhaustion", + "step_non_retryable", + "step_permanent_error", + }) { + const auto result = runner_for(test_case, "null").run(); + CHECK(result.status() == durable::local_run_status::failed); + } + + auto wait = runner_for("wait_basic", "null").run(); + CHECK(wait.status() == durable::local_run_status::succeeded); + CHECK(wait.invocation_count() == 2); + CHECK( + runner_for("wait_named", "null").run().status() == + durable::local_run_status::succeeded); + const auto sequential_wait = + runner_for("wait_sequential", "null").run(); + CHECK( + sequential_wait.status() == + durable::local_run_status::succeeded); + CHECK(sequential_wait.output().result == R"({"completedWaits":2})"); + CHECK( + runner_for("wait_minutes", "null").run().status() == + durable::local_run_status::succeeded); + CHECK( + runner_for("wait_hour", "null").run().status() == + durable::local_run_status::succeeded); + + auto child = runner_for("child_basic", R"("child")").run(); + CHECK(child.status() == durable::local_run_status::succeeded); + CHECK(child.output().result == R"("child")"); + + const std::vector< + std::tuple> + successful_child_cases{ + {"child_named", R"({"name":"named-child","value":"value"})", + R"("value")"}, + {"child_sequential", R"("sequential")", R"("sequential")"}, + {"child_error_caught", R"("recovered")", R"("recovered")"}, + {"child_nested", R"("nested")", R"("nested")"}, + {"child_retry", R"("retried")", R"("retried")"}, + {"child_replay", R"("replayed")", R"("replayed")"}, + {"child_step_wait", R"("mixed")", R"("mixed")"}, + {"child_large_replay", R"("large")", "null"}, + {"child_wait_then_step", R"("after-wait")", + R"("after-wait")"}, + {"child_custom_serdes", R"("hello child")", + R"("HELLO CHILD")"}, + {"child_null", "null", "null"}, + {"child_logger_replay", R"("logged")", R"("logged")"}, + {"child_mixed", R"("multi-replay")", R"("multi-replay")"}, + }; + for (const auto& [test_case, input, expected] : + successful_child_cases) { + const auto result = runner_for(test_case, input).run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == expected); + } + for (const auto test_case : { + "child_error", + "child_retry_exhaustion", + "child_direct_error", + }) { + CHECK( + runner_for(test_case, "null").run().status() == + durable::local_run_status::failed); + } + + auto condition = + runner_for("wait_for_condition_basic", "3").run(); + CHECK(condition.status() == durable::local_run_status::succeeded); + CHECK(condition.output().result == "3"); + + const std::vector> + successful_condition_cases{ + {"wait_for_condition_immediate", "5", "5"}, + {"wait_for_condition_named", "2", "2"}, + {"wait_for_condition_initial", "8", "8"}, + {"wait_for_condition_fixed", "3", "3"}, + {"wait_for_condition_error_caught", "null", + R"("recovered")"}, + {"wait_for_condition_object", "null", + R"({"status":"DONE","attempts":2})"}, + {"wait_for_condition_null", "null", "null"}, + {"wait_for_condition_serdes", "null", R"("xx")"}, + {"wait_for_condition_then_step", "2", "20"}, + {"wait_for_condition_sequential", "null", "4"}, + }; + for (const auto& [test_case, input, expected] : + successful_condition_cases) { + const auto result = runner_for(test_case, input).run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == expected); + } + for (const auto test_case : { + "wait_for_condition_exhausted", + "wait_for_condition_error", + }) { + CHECK( + runner_for(test_case, "null").run().status() == + durable::local_run_status::failed); + } + + const std::vector< + std::tuple> + successful_plugin_cases{ + {"plugin_invocation_lifecycle", R"("World")", + R"("Hello, World!")"}, + {"plugin_operation_lifecycle", R"("World")", + R"("Hello, World!")"}, + {"plugin_attempt_hooks", "null", + R"("Operation succeeded")"}, + {"plugin_error_isolation", R"("World")", + R"("Hello, World!")"}, + {"plugin_multiple", R"("World")", R"("Hello, World!")"}, + {"plugin_first_invocation", "null", R"("Wait completed")"}, + {"plugin_operation_change", R"("World")", + R"("Hello, World!")"}, + {"plugin_external_update", "null", R"("Wait completed")"}, + {"plugin_wait_lifecycle", "null", R"("Wait completed")"}, + {"plugin_parent_linkage", R"("World")", + R"("Hello, World!")"}, + {"plugin_parallel_functions", "null", + R"(["task-1","task-2"])"}, + {"plugin_replay_flags", "null", + R"("Operation succeeded")"}, + {"plugin_suspension", "null", R"("Wait completed")"}, + {"plugin_faulty_healthy", R"("World")", + R"("Hello, World!")"}, + {"plugin_wait_replay", "null", + R"(["short-done","long-done"])"}, + {"plugin_invocation_shape", R"("shape")", + R"("done-shape")"}, + {"plugin_operation_shape", "null", R"("task-a")"}, + {"plugin_attempt_shape", "null", R"("ok")"}, + {"plugin_change_shape", "null", R"("task-a")"}, + {"plugin_context_shape", "null", + R"(["a-done","b-done"])"}, + }; + for (const auto& [test_case, input, expected] : + successful_plugin_cases) { + const auto result = runner_for(test_case, input).run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == expected); + } + for (const auto test_case : { + "plugin_terminal_failure", + "plugin_terminal_payloads", + "plugin_retry_exhaustion", + }) { + CHECK( + runner_for(test_case, "null").run().status() == + durable::local_run_status::failed); + } + + auto parallel = runner_for("parallel_basic", "null").run(); + CHECK(parallel.status() == durable::local_run_status::succeeded); + CHECK(parallel.output().result == R"(["task-1","task-2"])"); + + const std::vector> + successful_parallel_cases{ + {"parallel_no_name", R"(["alpha","beta"])"}, + {"parallel_named", R"(["one","two"])"}, + {"parallel_heterogeneous", R"(["hello",42,{"k":"v"}])"}, + {"parallel_empty", "[]"}, + {"parallel_failfast", + R"({"completionReason":"FAILURE_TOLERANCE_EXCEEDED","status":"FAILED","successCount":1,"failureCount":1,"totalCount":2})"}, + {"parallel_min_successful", + R"({"completionReason":"MIN_SUCCESSFUL_REACHED","successCount":2,"totalCount":2})"}, + {"parallel_tolerated", + R"({"completionReason":"ALL_COMPLETED","status":"FAILED","successCount":2,"failureCount":1,"totalCount":3})"}, + {"parallel_tolerated_exceeded", + R"({"completionReason":"FAILURE_TOLERANCE_EXCEEDED","successCount":0,"failureCount":2,"totalCount":2})"}, + {"parallel_concurrent", R"(["r0","r1","r2"])"}, + {"parallel_flat", R"(["fa","fb"])"}, + {"parallel_pct_exceeded", + R"({"completionReason":"FAILURE_TOLERANCE_EXCEEDED","successCount":0,"failureCount":2,"totalCount":2})"}, + {"parallel_replay", R"(["b0","b1"])"}, + {"parallel_serde", R"(["x","y"])"}, + {"parallel_all_fail", + R"({"completionReason":"ALL_COMPLETED","status":"FAILED","successCount":0,"failureCount":3,"totalCount":3})"}, + {"parallel_min_not_reached", + R"({"completionReason":"ALL_COMPLETED","status":"FAILED","successCount":2,"failureCount":1,"totalCount":3})"}, + {"parallel_combined", + R"({"completionReason":"FAILURE_TOLERANCE_EXCEEDED","successCount":0,"failureCount":2,"totalCount":2})"}, + {"parallel_accessors", + R"({"hasFailure":true,"successCount":2,"failureCount":1,"errorCount":1})"}, + {"parallel_nested", R"([["i1","i2"]])"}, + {"parallel_pct_boundary", + R"({"completionReason":"ALL_COMPLETED","status":"FAILED","successCount":3,"failureCount":1,"totalCount":4})"}, + }; + for (const auto& [test_case, expected] : + successful_parallel_cases) { + const auto result = runner_for(test_case, "null").run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == expected); + } + for (const auto test_case : { + "parallel_throw", + "parallel_bad_concurrency", + }) { + CHECK( + runner_for(test_case, "null").run().status() == + durable::local_run_status::failed); + } + + auto map = + runner_for("map_basic", R"(["World","Kiro"])").run(); + CHECK(map.status() == durable::local_run_status::succeeded); + CHECK( + map.output().result == + R"(["Hello, World!","Hello, Kiro!"])"); + + const std::vector< + std::tuple> + successful_map_cases{ + {"map_items_only", "[1,2]", "[2,4]"}, + {"map_indexed", "[10,20,30]", "[10,21,32]"}, + {"map_empty", "[]", "[]"}, + {"map_failfast", "null", + R"({"completionReason":"FAILURE_TOLERANCE_EXCEEDED","status":"FAILED","successCount":1,"failureCount":1,"totalCount":2})"}, + {"map_min_successful", "null", + R"({"completionReason":"MIN_SUCCESSFUL_REACHED","successCount":2,"totalCount":2})"}, + {"map_tolerated", "null", + R"({"completionReason":"ALL_COMPLETED","status":"FAILED","successCount":2,"failureCount":1,"totalCount":3})"}, + {"map_tolerated_exceeded", "null", + R"({"completionReason":"FAILURE_TOLERANCE_EXCEEDED","successCount":0,"failureCount":2,"totalCount":2})"}, + {"map_pct_exceeded", "null", + R"({"completionReason":"FAILURE_TOLERANCE_EXCEEDED","successCount":0,"failureCount":2,"totalCount":2})"}, + {"map_concurrent", "null", R"(["r0","r1","r2"])"}, + {"map_flat", "null", R"(["fa","fb"])"}, + {"map_named_items", "[1,2]", "[10,20]"}, + {"map_serde", "null", R"(["X","Y"])"}, + {"map_suspend", "null", R"(["r0","r1"])"}, + {"map_large", "null", R"({"successCount":4,"totalCount":4})"}, + {"map_then_wait", "null", R"(["A","B"])"}, + {"map_fail_then_wait", "null", + R"({"completionReason":"ALL_COMPLETED","status":"FAILED","successCount":1,"failureCount":1,"totalCount":2})"}, + {"map_operation_serde", "null", R"(["X","Y"])"}, + {"map_operation_serde_replay", "null", R"(["X","Y"])"}, + }; + for (const auto& [test_case, input, expected] : successful_map_cases) { + const auto result = runner_for(test_case, input).run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == expected); + } + CHECK( + runner_for("map_throw", "null").run().status() == + durable::local_run_status::failed); +} + +void run_callback_cases() { + const auto failure = durable::error_object{ + .message = "not approved", + .type = "RejectedError", + }; + + auto callback = runner_for("callback_basic", R"("approval")"); + auto pending = callback.run(); + CHECK( + pending.status() == durable::local_run_status::pending_external); + const auto ids = pending.pending_callback_ids(); + CHECK(ids.size() == 1); + callback.send_callback_success(ids.front(), R"("approved")"); + auto completed = callback.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("approved")"); + + auto named = runner_for("callback_named", "null"); + pending = named.run(); + named.send_callback_success( + pending.pending_callback_ids().front(), R"("named-result")"); + completed = named.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("named-result")"); + + for (const auto test_case : { + "callback_timeout", + "callback_heartbeat_timeout", + "callback_step_timeout", + "callback_wait_timeout", + }) { + const auto result = runner_for( + test_case, R"("timeout")", {}, true).run(); + CHECK(result.status() == durable::local_run_status::failed); + } + + auto heartbeat = + runner_for("callback_heartbeat_success", R"("heartbeat")"); + pending = heartbeat.run(); + const auto heartbeat_id = pending.pending_callback_ids().front(); + heartbeat.send_callback_heartbeat(heartbeat_id); + heartbeat.send_callback_success(heartbeat_id, R"("alive")"); + completed = heartbeat.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("alive")"); + + for (const auto test_case : { + "callback_failure", + "callback_step_failure", + "callback_wait_failure", + }) { + auto failing = runner_for(test_case, R"("rejected")"); + pending = failing.run(); + failing.send_callback_failure( + pending.pending_callback_ids().front(), failure); + completed = failing.resume(); + CHECK(completed.status() == durable::local_run_status::failed); + } + + auto wait_success = + runner_for("callback_wait_success", R"("during-wait")"); + pending = wait_success.run(); + wait_success.send_callback_success( + pending.pending_callback_ids().front(), R"("wait-result")"); + completed = wait_success.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("wait-result")"); + + auto then_wait = + runner_for("callback_then_wait", R"("before-wait")"); + pending = then_wait.run(); + then_wait.send_callback_success( + pending.pending_callback_ids().front(), R"("then-wait-result")"); + completed = then_wait.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("then-wait-result")"); + + auto caught = + runner_for("callback_failure_caught", R"("caught-failure")"); + pending = caught.run(); + caught.send_callback_failure( + pending.pending_callback_ids().front(), failure); + CHECK( + caught.resume().status() == + durable::local_run_status::succeeded); + + CHECK( + runner_for( + "callback_timeout_caught", R"("caught-timeout")", {}, true) + .run() + .status() == durable::local_run_status::succeeded); + + auto object = + runner_for("callback_custom_object", R"("object")"); + pending = object.run(); + object.send_callback_success( + pending.pending_callback_ids().front(), + R"({"id":"id-123","message":"hello","timestamp":"2026-01-01T00:00:00.000Z"})"); + completed = object.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK( + completed.output().result == + R"({"received":{"id":"id-123","message":"hello","timestamp":1767225600}})"); + + auto number = + runner_for("callback_custom_number", R"("number")"); + pending = number.run(); + number.send_callback_success( + pending.pending_callback_ids().front(), "42"); + completed = number.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"({"count":42,"doubled":84})"); + + auto sequential = runner_for( + "callback_two_sequential", R"(["first","second"])"); + pending = sequential.run(); + sequential.send_callback_success( + pending.pending_callback_ids().front(), R"("one")"); + pending = sequential.resume(); + CHECK( + pending.status() == durable::local_run_status::pending_external); + sequential.send_callback_success( + pending.pending_callback_ids().front(), R"("two")"); + completed = sequential.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"(["one","two"])"); + + for (const auto test_case : { + "callback_two_ordered", + "callback_two_reverse", + }) { + auto two = runner_for(test_case, R"(["first","second"])"); + pending = two.run(); + const auto two_ids = pending.pending_callback_ids(); + CHECK(two_ids.size() == 2); + two.send_callback_success(two_ids.at(0), R"("one")"); + pending = two.resume(); + CHECK( + pending.status() == + durable::local_run_status::pending_external); + two.send_callback_success(two_ids.at(1), R"("two")"); + completed = two.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"(["one","two"])"); + } + + auto wait_callback = + runner_for("wait_for_callback_basic", R"("approval")"); + pending = wait_callback.run(); + const auto nested_ids = pending.pending_callback_ids(); + CHECK(nested_ids.size() == 1); + wait_callback.send_callback_success( + nested_ids.front(), R"("approved")"); + completed = wait_callback.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("approved")"); + + for (const auto test_case : { + "wait_for_callback_named", + "wait_for_callback_anonymous", + "wait_for_callback_child", + "wait_for_callback_mixed", + }) { + auto runner = runner_for( + test_case, + test_case == std::string_view{"wait_for_callback_anonymous"} + ? "null" + : R"("approval")"); + pending = runner.run(); + runner.send_callback_success( + pending.pending_callback_ids().front(), R"("approved")"); + completed = runner.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("approved")"); + } + + auto wait_failure = + runner_for("wait_for_callback_failure", R"("failure")"); + pending = wait_failure.run(); + wait_failure.send_callback_failure( + pending.pending_callback_ids().front(), failure); + CHECK( + wait_failure.resume().status() == + durable::local_run_status::failed); + + CHECK( + runner_for( + "wait_for_callback_timeout", R"("timeout")", {}, true) + .run() + .status() == durable::local_run_status::failed); + + auto wait_caught = runner_for( + "wait_for_callback_failure_caught", R"("caught")"); + pending = wait_caught.run(); + wait_caught.send_callback_failure( + pending.pending_callback_ids().front(), failure); + completed = wait_caught.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("recovered")"); + + CHECK( + runner_for( + "wait_for_callback_submitter_exhausted", + R"("submitter")") + .run() + .status() == durable::local_run_status::failed); + + auto wait_sequential = + runner_for("wait_for_callback_sequential", "null"); + pending = wait_sequential.run(); + wait_sequential.send_callback_success( + pending.pending_callback_ids().front(), R"("first-result")"); + pending = wait_sequential.resume(); + CHECK( + pending.status() == durable::local_run_status::pending_external); + wait_sequential.send_callback_success( + pending.pending_callback_ids().front(), R"("second-result")"); + completed = wait_sequential.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("second-result")"); + + auto wait_object = + runner_for("wait_for_callback_object", R"("object")"); + pending = wait_object.run(); + wait_object.send_callback_success( + pending.pending_callback_ids().front(), + R"({"status":"approved"})"); + completed = wait_object.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("approved")"); + + CHECK( + runner_for( + "wait_for_callback_heartbeat_timeout", + R"("heartbeat-timeout")", {}, true) + .run() + .status() == durable::local_run_status::failed); + + auto wait_heartbeat = runner_for( + "wait_for_callback_heartbeat_success", R"("heartbeat")"); + pending = wait_heartbeat.run(); + const auto wait_heartbeat_id = + pending.pending_callback_ids().front(); + wait_heartbeat.send_callback_heartbeat(wait_heartbeat_id); + wait_heartbeat.send_callback_success( + wait_heartbeat_id, R"("heartbeat-result")"); + completed = wait_heartbeat.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("heartbeat-result")"); + + completed = runner_for( + "wait_for_callback_timeout_caught", + R"("caught-timeout")", {}, true) + .run(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == R"("timed-out-handled")"); + + auto wait_empty = + runner_for("wait_for_callback_empty", R"("empty")"); + pending = wait_empty.run(); + wait_empty.send_callback_success( + pending.pending_callback_ids().front()); + completed = wait_empty.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.output().result == "null"); +} + +void run_invoke_case() { + const durable_conformance::environment environment{ + .target_function_name = "target-echo:prod", + .error_function_name = "target-error:prod", + }; + + auto invoke = runner_for( + "invoke_basic", R"("echo")", environment); + invoke.mock_invoke_success("target-echo:prod", R"("echo")"); + auto result = invoke.run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == R"("echo")"); + + const std::vector< + std::tuple> + successful_cases{ + {"invoke_named", R"({"name":"named","payload":"value"})", + R"("value")", R"("value")"}, + {"invoke_complex", + R"({"nested":{"key":"value","items":[1,2,3]},"source":"test"})", + R"({"nested":{"key":"value","items":[1,2,3]},"source":"test"})", + R"({"nested":{"key":"value","items":[1,2,3]},"source":"test"})"}, + {"invoke_null", "null", "null", "null"}, + {"invoke_large", "null", R"("large-result")", "null"}, + {"invoke_tenant", + R"({"tenantId":"tenant-1","payload":"tenant-value"})", + R"("tenant-value")", R"("tenant-value")"}, + {"invoke_replay", "null", R"("cached")", R"("cached")"}, + {"step_then_invoke", "null", R"("step-result")", + R"("step-result")"}, + {"invoke_then_step", "null", R"("invoke-result")", + R"("invoke-result")"}, + {"invoke_in_child", "null", R"("child-invoke")", + R"("child-invoke")"}, + {"invoke_sequential", "null", R"("sequential")", + R"("sequential")"}, + {"invoke_payload_serdes", R"({"data":"hello"})", + R"("HELLO")", R"("HELLO")"}, + {"invoke_result_serdes", R"("hello")", R"("hello")", + R"("\"HELLO\"")"}, + }; + for (const auto& [test_case, input, mocked, expected] : + successful_cases) { + auto runner = runner_for(test_case, input, environment); + runner.mock_invoke_success("target-echo:prod", mocked); + result = runner.run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == expected); + } + + for (const auto test_case : { + "invoke_failure_caught", + "invoke_replay_failure", + }) { + auto runner = runner_for(test_case, "null", environment); + runner.mock_invoke_failure( + "target-error:prod", + durable::error_object{ + .message = "target function failed", + .type = "TargetError", + }); + result = runner.run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.output().result == R"("fallback")"); + } + + auto failing = runner_for("invoke_failure", "null", environment); + failing.mock_invoke_failure( + "target-error:prod", + durable::error_object{ + .message = "target function failed", + .type = "TargetError", + }); + CHECK( + failing.run().status() == durable::local_run_status::failed); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc == 3) { + const auto result = runner_for(argv[1], argv[2]).run(); + for (const auto& operation : result.operations()) { + std::cerr + << "LOCAL_OPERATION\t" << operation.operation_id << '\t' + << operation.type.wire_value() << '\t' + << operation.sub_type.value_or("") << '\t' + << operation.name.value_or("") << '\t' + << operation.parent_id.value_or("") << '\n'; + } + std::cerr << "LOCAL_CONFORMANCE_STATUS=" + << static_cast(result.status()) << '\n'; + return result.status() == durable::local_run_status::succeeded || + result.status() == durable::local_run_status::failed + ? 0 + : 2; + } + + run_sync_cases(); + run_callback_cases(); + run_invoke_case(); + if (failures != 0) { + std::cerr << failures << " conformance assertion(s) failed\n"; + return 1; + } + std::cout << "Foundational local conformance cases passed\n"; + return 0; +} diff --git a/conformance/main.cpp b/conformance/main.cpp new file mode 100644 index 0000000..2ca8191 --- /dev/null +++ b/conformance/main.cpp @@ -0,0 +1,48 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include "aws/durable_execution/aws_sdk_service_client.hpp" +#include "aws/durable_execution/lambda_runtime.hpp" +#include "conformance/handlers.hpp" +#include "conformance/plugins.hpp" + +int main() { + const char* case_value = std::getenv("CONFORMANCE_CASE"); + if (!case_value || *case_value == '\0') { + throw std::runtime_error("CONFORMANCE_CASE is required"); + } + durable_conformance::environment environment; + if (const char* target = std::getenv("TARGET_FUNCTION_NAME")) { + environment.target_function_name = target; + } + if (const char* target = std::getenv("ERROR_FUNCTION_NAME")) { + environment.error_function_name = target; + } + + Aws::SDKOptions options; + Aws::InitAPI(options); + { + auto lambda_client = std::make_shared(); + aws::durable_execution::aws_sdk_service_client service{lambda_client}; + const std::string test_case{case_value}; + auto run_configuration = + durable_conformance::plugins_for(test_case); + auto handler = aws::durable_execution::make_lambda_handler( + service, + [test_case, environment](std::string_view event) { + return durable_conformance::dispatch( + test_case, event, environment); + }, + aws::durable_execution::passthrough_serdes{}, + std::move(run_configuration)); + aws::lambda_runtime::run_handler(handler); + } + Aws::ShutdownAPI(options); + return 0; +} diff --git a/conformance/plugins.hpp b/conformance/plugins.hpp new file mode 100644 index 0000000..1c5552d --- /dev/null +++ b/conformance/plugins.hpp @@ -0,0 +1,759 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/plugin.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace durable_conformance { + +namespace durable = aws::durable_execution; + +enum class plugin_profile { + invocation_lifecycle, + operation_lifecycle, + attempt_hooks, + faulty_all, + healthy_all, + operation_change, + external_update, + wait_lifecycle, + nested_parent, + branch_functions, + replay_flags, + terminal_payloads, + retry_exhaustion, + suspension, + wait_replay, + invocation_shape, + operation_shape, + attempt_shape, + change_shape, + context_shape, +}; + +class json_record { + public: + json_record() : value_{"{"} {} + + void string(std::string_view key, std::string_view value) { + prefix(key); + value_.append( + durable::default_serdes{}.serialize( + std::string{value}, {})); + } + + void boolean(std::string_view key, bool value) { + prefix(key); + value_.append(value ? "true" : "false"); + } + + void number(std::string_view key, std::uint64_t value) { + prefix(key); + value_.append(std::to_string(value)); + } + + [[nodiscard]] std::string finish(std::string_view execution_arn) { + if (!execution_arn.empty()) { + string("durableExecutionArn", execution_arn); + } + value_.push_back('}'); + return std::move(value_); + } + + private: + void prefix(std::string_view key) { + if (!first_) value_.push_back(','); + first_ = false; + value_.append( + durable::default_serdes{}.serialize( + std::string{key}, {})); + value_.push_back(':'); + } + + std::string value_; + bool first_{true}; +}; + +[[nodiscard]] inline std::string iso_timestamp(durable::timestamp value) { + const auto milliseconds = + std::chrono::duration_cast( + value.time_since_epoch()); + const auto seconds = + std::chrono::duration_cast(milliseconds); + const auto fractional = milliseconds - seconds; + const std::time_t time = static_cast(seconds.count()); + std::tm utc{}; + gmtime_r(&time, &utc); + std::ostringstream output; + output << std::put_time(&utc, "%Y-%m-%dT%H:%M:%S") + << '.' << std::setw(3) << std::setfill('0') + << fractional.count() << 'Z'; + return output.str(); +} + +class conformance_plugin final : public durable::instrumentation_plugin { + public: + conformance_plugin( + plugin_profile profile, std::string label = "CONFPLUGIN", + bool include_first = true, bool throws_after_hook = false) + : profile_(profile), + label_(std::move(label)), + include_first_(include_first), + throws_after_hook_(throws_after_hook) {} + + void on_invocation_start( + const durable::invocation_info& info) override { + switch (profile_) { + case plugin_profile::invocation_lifecycle: { + json_record record; + record.string("plugin", label_); + record.string("hook", "invocation-start"); + if (include_first_) { + record.boolean("first", info.is_first_invocation); + } + emit(std::move(record), info.execution_arn); + maybe_throw(); + break; + } + case plugin_profile::faulty_all: { + emit_hook("invocation-start", info.execution_arn); + maybe_throw(); + break; + } + case plugin_profile::healthy_all: { + json_record record; + record.string("plugin", label_); + record.string("hook", "invocation-start"); + record.boolean("first", info.is_first_invocation); + emit(std::move(record), info.execution_arn); + break; + } + case plugin_profile::external_update: + for (const auto& operation : info.updated_operations) { + if (operation.type != "WAIT") continue; + json_record record; + record.string("plugin", label_); + record.string("hook", "updated-on-invoke"); + record.string("op", operation.id); + if (operation.status) { + record.string("status", *operation.status); + } + record.boolean("first", info.is_first_invocation); + emit(std::move(record), info.execution_arn); + } + break; + case plugin_profile::invocation_shape: { + json_record record; + record.string("plugin", label_); + record.string("hook", "invocation-start"); + record.boolean( + "isFirstInvocation", info.is_first_invocation); + record.string("requestId", info.request_id); + record.number("operationsCount", info.operations.size()); + record.number( + "updatedOperationsCount", + info.updated_operations.size()); + if (info.execution_start_timestamp) { + record.string( + "executionStartTimestamp", + iso_timestamp(*info.execution_start_timestamp)); + } + emit(std::move(record), info.execution_arn); + break; + } + default: + break; + } + } + + void on_invocation_end( + const durable::invocation_end_info& info) override { + switch (profile_) { + case plugin_profile::invocation_lifecycle: { + json_record record; + record.string("plugin", label_); + record.string("hook", "invocation-end"); + record.string("status", durable::to_string(info.status)); + emit(std::move(record), info.execution_arn); + maybe_throw(); + break; + } + case plugin_profile::faulty_all: + emit_hook("invocation-end", info.execution_arn); + maybe_throw(); + break; + case plugin_profile::healthy_all: { + json_record record; + record.string("plugin", label_); + record.string("hook", "invocation-end"); + record.string("status", durable::to_string(info.status)); + emit(std::move(record), info.execution_arn); + break; + } + case plugin_profile::suspension: { + const bool terminal = + info.status == durable::invocation_status::succeeded || + info.status == durable::invocation_status::failed; + json_record record; + record.string("plugin", label_); + record.string("hook", "invocation-end"); + record.boolean("first", info.is_first_invocation); + record.boolean("terminal", terminal); + record.string("status", durable::to_string(info.status)); + emit(std::move(record), info.execution_arn); + break; + } + case plugin_profile::invocation_shape: { + const bool terminal = + info.status == durable::invocation_status::succeeded || + info.status == durable::invocation_status::failed; + json_record record; + record.string("plugin", label_); + record.string("hook", "invocation-end"); + record.boolean( + "isFirstInvocation", info.is_first_invocation); + record.string("requestId", info.request_id); + record.number("operationsCount", info.operations.size()); + if (info.execution_start_timestamp) { + record.string( + "executionStartTimestamp", + iso_timestamp(*info.execution_start_timestamp)); + } + record.string("status", durable::to_string(info.status)); + record.boolean("terminal", terminal); + if (info.execution_error && + info.execution_error->message) { + record.string( + "executionError", + *info.execution_error->message); + } + emit(std::move(record), info.execution_arn); + break; + } + default: + break; + } + } + + void on_operation_start( + const durable::operation_info& info) override { + switch (profile_) { + case plugin_profile::operation_lifecycle: + if (info.sub_type == "Step") { + emit_operation_id("operation-start", info); + } + break; + case plugin_profile::faulty_all: + if (info.type == "STEP") { + emit_hook("operation-start", info.durable_execution_arn); + maybe_throw(); + } + break; + case plugin_profile::healthy_all: + if (info.type == "STEP") { + emit_operation_id("operation-start", info); + } + break; + case plugin_profile::wait_lifecycle: + if (info.type == "WAIT") { + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-start"); + record.string("op", info.id); + record.string("type", info.type); + emit(std::move(record), info.durable_execution_arn); + } + break; + case plugin_profile::replay_flags: + if (info.type == "STEP") { + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-start"); + record.string("op", info.id); + record.boolean("replay", info.is_replay); + emit(std::move(record), info.durable_execution_arn); + } + break; + case plugin_profile::wait_replay: + if (info.type == "WAIT") { + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-start"); + record.string("type", info.type); + if (info.name) record.string("name", *info.name); + record.boolean("replay", info.is_replay); + record.boolean("pending", !info.end_timestamp.has_value()); + emit(std::move(record), info.durable_execution_arn); + } + break; + case plugin_profile::operation_shape: + if (info.type == "STEP") { + emit_operation_shape("operation-start", info); + } + break; + case plugin_profile::context_shape: + if (info.type == "CONTEXT") { + emit_operation_shape("operation-start", info); + } + break; + default: + break; + } + } + + void on_operation_end( + const durable::operation_info& info) override { + switch (profile_) { + case plugin_profile::operation_lifecycle: + if (info.sub_type == "Step") { + emit_operation_status(info); + } + break; + case plugin_profile::faulty_all: + if (info.type == "STEP") { + emit_hook("operation-end", info.durable_execution_arn); + maybe_throw(); + } + break; + case plugin_profile::healthy_all: + if (info.type == "STEP") emit_operation_status(info); + break; + case plugin_profile::wait_lifecycle: + case plugin_profile::wait_replay: + if (info.type == "WAIT") { + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-end"); + if (profile_ == plugin_profile::wait_replay && + info.name) { + record.string("name", *info.name); + } + record.string("type", info.type); + if (info.status) record.string("status", *info.status); + if (profile_ == plugin_profile::wait_lifecycle) { + record.string("op", info.id); + } + emit(std::move(record), info.durable_execution_arn); + } + break; + case plugin_profile::nested_parent: { + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-end"); + record.string("op", info.id); + record.string( + "parent", info.parent_id ? *info.parent_id : "NONE"); + if (info.status) record.string("status", *info.status); + emit(std::move(record), info.durable_execution_arn); + break; + } + case plugin_profile::replay_flags: + case plugin_profile::retry_exhaustion: + if (info.type == "STEP") emit_operation_status(info); + break; + case plugin_profile::terminal_payloads: + if (info.type == "STEP") { + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-end"); + record.string("op", info.id); + if (info.status) record.string("status", *info.status); + record.string( + "result", info.result ? *info.result : "NONE"); + record.string( + "error", + info.error && info.error->message + ? std::string_view{*info.error->message} + : std::string_view{"NONE"}); + emit(std::move(record), info.durable_execution_arn); + } + break; + case plugin_profile::operation_shape: + if (info.type == "STEP") { + emit_operation_shape("operation-end", info); + } + break; + default: + break; + } + } + + void on_attempt_start( + const durable::attempt_info& info) override { + const auto& operation = info.operation; + switch (profile_) { + case plugin_profile::attempt_hooks: + case plugin_profile::retry_exhaustion: + if (operation.type == "STEP") { + emit_attempt("attempt-start", info, std::nullopt); + } + break; + case plugin_profile::faulty_all: + if (operation.type == "STEP") { + emit_hook("attempt-start", operation.durable_execution_arn); + maybe_throw(); + } + break; + case plugin_profile::healthy_all: + if (operation.type == "STEP") { + emit_attempt_id("attempt-start", info); + } + break; + case plugin_profile::branch_functions: + if (operation.sub_type == "ParallelBranch") { + json_record record; + record.string("plugin", label_); + record.string("hook", "fn-start"); + record.string("op", operation.id); + record.string( + "parent", + operation.parent_id ? *operation.parent_id : "NONE"); + emit(std::move(record), operation.durable_execution_arn); + } + break; + case plugin_profile::attempt_shape: + if (operation.type == "STEP") { + emit_attempt_shape("attempt-start", info); + } + break; + case plugin_profile::context_shape: + if (operation.type == "CONTEXT") { + json_record record; + record.string("plugin", label_); + record.string("hook", "fn-start"); + append_operation_identity(record, operation); + record.boolean( + "isReplayingChildren", + operation.is_replaying_children); + emit(std::move(record), operation.durable_execution_arn); + } + break; + default: + break; + } + } + + void on_attempt_end( + const durable::attempt_info& info) override { + const auto& operation = info.operation; + switch (profile_) { + case plugin_profile::attempt_hooks: + case plugin_profile::retry_exhaustion: + if (operation.type == "STEP") { + emit_attempt("attempt-end", info, info.succeeded); + } + break; + case plugin_profile::faulty_all: + if (operation.type == "STEP") { + emit_hook("attempt-end", operation.durable_execution_arn); + maybe_throw(); + } + break; + case plugin_profile::healthy_all: + if (operation.type == "STEP") { + json_record record; + record.string("plugin", label_); + record.string("hook", "attempt-end"); + record.string("op", operation.id); + record.string( + "outcome", + info.succeeded.value_or(false) + ? "SUCCEEDED" + : "FAILED"); + emit(std::move(record), operation.durable_execution_arn); + } + break; + case plugin_profile::branch_functions: + if (operation.sub_type == "ParallelBranch") { + json_record record; + record.string("plugin", label_); + record.string("hook", "fn-end"); + record.string("op", operation.id); + record.string( + "parent", + operation.parent_id ? *operation.parent_id : "NONE"); + record.string( + "outcome", + info.succeeded.value_or(false) + ? "SUCCEEDED" + : "FAILED"); + emit(std::move(record), operation.durable_execution_arn); + } + break; + case plugin_profile::attempt_shape: + if (operation.type == "STEP") { + emit_attempt_shape("attempt-end", info); + } + break; + default: + break; + } + } + + void on_operation_change( + const durable::operation_change_info& info) override { + switch (profile_) { + case plugin_profile::operation_change: + for (const auto& operation : info.updated_operations) { + if (operation.type != "STEP") continue; + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-change"); + record.string("op", operation.id); + if (operation.status) { + record.string("status", *operation.status); + } + record.boolean( + "in_full_map", + contains_operation(info.operations, operation.id)); + emit(std::move(record), info.execution_arn); + } + break; + case plugin_profile::change_shape: + for (const auto& operation : info.updated_operations) { + if (operation.type != "STEP") continue; + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-change"); + record.string("executionArn", info.execution_arn); + record.number( + "updatedOperationsCount", + info.updated_operations.size()); + record.number("operationsCount", info.operations.size()); + record.boolean( + "inFullMap", + contains_operation(info.operations, operation.id)); + append_operation_shape(record, operation); + emit(std::move(record), info.execution_arn); + } + break; + default: + break; + } + } + + private: + static void emit(json_record record, std::string_view arn) { + static std::mutex output_mutex; + std::lock_guard lock{output_mutex}; + std::cout << record.finish(arn) << std::endl; + } + + void emit_hook(std::string_view hook, std::string_view arn) const { + json_record record; + record.string("plugin", label_); + record.string("hook", hook); + emit(std::move(record), arn); + } + + void emit_operation_id( + std::string_view hook, + const durable::operation_info& info) const { + json_record record; + record.string("plugin", label_); + record.string("hook", hook); + record.string("op", info.id); + emit(std::move(record), info.durable_execution_arn); + } + + void emit_operation_status( + const durable::operation_info& info) const { + json_record record; + record.string("plugin", label_); + record.string("hook", "operation-end"); + record.string("op", info.id); + if (info.status) record.string("status", *info.status); + emit(std::move(record), info.durable_execution_arn); + } + + void emit_attempt_id( + std::string_view hook, + const durable::attempt_info& info) const { + json_record record; + record.string("plugin", label_); + record.string("hook", hook); + record.string("op", info.operation.id); + emit(std::move(record), info.operation.durable_execution_arn); + } + + void emit_attempt( + std::string_view hook, const durable::attempt_info& info, + std::optional succeeded) const { + json_record record; + record.string("plugin", label_); + record.string("hook", hook); + record.number("n", info.attempt); + if (succeeded) { + record.string( + "outcome", *succeeded ? "SUCCEEDED" : "FAILED"); + } + record.string("op", info.operation.id); + emit(std::move(record), info.operation.durable_execution_arn); + } + + static void append_operation_identity( + json_record& record, const durable::operation_info& info) { + record.string("id", info.id); + if (info.name) record.string("name", *info.name); + record.string("type", info.type); + if (info.sub_type) record.string("subType", *info.sub_type); + if (info.parent_id) record.string("parentId", *info.parent_id); + } + + static void append_operation_shape( + json_record& record, const durable::operation_info& info) { + append_operation_identity(record, info); + if (info.status) record.string("status", *info.status); + if (info.start_timestamp) { + record.string( + "startTimestamp", iso_timestamp(*info.start_timestamp)); + } + if (info.end_timestamp) { + record.string( + "endTimestamp", iso_timestamp(*info.end_timestamp)); + } + if (info.error && info.error->message) { + record.string("error", *info.error->message); + } + if (info.attempt) record.number("attempt", *info.attempt); + record.boolean("isReplay", info.is_replay); + } + + void emit_operation_shape( + std::string_view hook, + const durable::operation_info& info) const { + json_record record; + record.string("plugin", label_); + record.string("hook", hook); + append_operation_shape(record, info); + emit(std::move(record), info.durable_execution_arn); + } + + void emit_attempt_shape( + std::string_view hook, + const durable::attempt_info& info) const { + json_record record; + record.string("plugin", label_); + record.string("hook", hook); + append_operation_identity(record, info.operation); + record.number("attempt", info.attempt); + record.string( + "startTimestamp", iso_timestamp(info.start_timestamp)); + if (info.end_timestamp) { + record.string( + "endTimestamp", iso_timestamp(*info.end_timestamp)); + } + record.boolean("isReplay", info.operation.is_replay); + if (info.succeeded) { + record.string( + "outcome", *info.succeeded ? "SUCCEEDED" : "FAILED"); + } + if (info.error && info.error->message) { + record.string("error", *info.error->message); + } + emit(std::move(record), info.operation.durable_execution_arn); + } + + [[nodiscard]] static bool contains_operation( + std::span operations, + std::string_view id) { + for (const auto& operation : operations) { + if (operation.id == id) return true; + } + return false; + } + + void maybe_throw() const { + if (throws_after_hook_) { + throw std::runtime_error{"conformance plugin failure"}; + } + } + + plugin_profile profile_; + std::string label_; + bool include_first_; + bool throws_after_hook_; +}; + +[[nodiscard]] inline durable::run_options plugins_for( + std::string_view test_case) { + using enum plugin_profile; + durable::run_options result; + const auto add = [&](plugin_profile profile, + std::string label = "CONFPLUGIN", + bool include_first = true, + bool throws_after_hook = false) { + result.plugins.push_back( + std::make_shared( + profile, std::move(label), include_first, + throws_after_hook)); + }; + + if (test_case == "plugin_invocation_lifecycle") { + add(invocation_lifecycle); + } else if (test_case == "plugin_operation_lifecycle") { + add(operation_lifecycle); + } else if (test_case == "plugin_attempt_hooks") { + add(attempt_hooks); + } else if (test_case == "plugin_error_isolation") { + add( + faulty_all, "CONFPLUGIN-FAULTY", true, + true); + } else if (test_case == "plugin_multiple") { + add(invocation_lifecycle, "CONFPLUGIN-A", false); + add(invocation_lifecycle, "CONFPLUGIN-B", false); + } else if ( + test_case == "plugin_first_invocation" || + test_case == "plugin_terminal_failure") { + add(invocation_lifecycle); + } else if (test_case == "plugin_operation_change") { + add(operation_change); + } else if (test_case == "plugin_external_update") { + add(external_update); + } else if (test_case == "plugin_wait_lifecycle") { + add(wait_lifecycle); + } else if (test_case == "plugin_parent_linkage") { + add(nested_parent); + } else if (test_case == "plugin_parallel_functions") { + add(branch_functions); + } else if (test_case == "plugin_replay_flags") { + add(replay_flags); + } else if (test_case == "plugin_terminal_payloads") { + add(terminal_payloads); + } else if (test_case == "plugin_retry_exhaustion") { + add(retry_exhaustion); + } else if (test_case == "plugin_suspension") { + add(suspension); + } else if (test_case == "plugin_faulty_healthy") { + add( + faulty_all, "CONFPLUGIN-FAULTY", true, + true); + add(healthy_all, "CONFPLUGIN-HEALTHY"); + } else if (test_case == "plugin_wait_replay") { + add(wait_replay); + } else if (test_case == "plugin_invocation_shape") { + add(invocation_shape); + } else if (test_case == "plugin_operation_shape") { + add(operation_shape); + } else if (test_case == "plugin_attempt_shape") { + add(attempt_shape); + } else if (test_case == "plugin_change_shape") { + add(change_shape); + } else if (test_case == "plugin_context_shape") { + add(context_shape); + } + return result; +} + +} // namespace durable_conformance diff --git a/conformance/supported.json b/conformance/supported.json new file mode 100644 index 0000000..0e8c9e0 --- /dev/null +++ b/conformance/supported.json @@ -0,0 +1,177 @@ +{ + "official_conformance_commit": "02d6dca971a38c13d94d6233d12f687e55b2a572", + "language": "cpp", + "cases": { + "1-1": "step_basic", + "1-2": "step_named", + "1-3": "step_sequential", + "1-4": "step_complex", + "1-5": "step_null", + "1-6": "step_custom_serdes", + "1-7": "step_logger", + "1-8": "step_then_wait", + "1-9": "step_replay_skips", + "1-10": "step_replay_rethrows", + "1-11": "step_retry_once", + "1-12": "step_retry_exhaustion", + "1-13": "step_default_retry", + "1-14": "step_custom_retry", + "1-15": "step_retry_specific", + "1-16": "step_non_retryable", + "1-17": "step_at_most_crash", + "1-18": "step_at_most_retry", + "1-19": "step_permanent_error", + "1-20": "step_error_caught", + "2-1": "wait_basic", + "2-2": "wait_named", + "2-3": "wait_sequential", + "2-4": "wait_minutes", + "2-5": "wait_hour", + "3-1": "child_basic", + "3-2": "child_named", + "3-3": "child_sequential", + "3-4": "child_error", + "3-5": "child_error_caught", + "3-6": "child_nested", + "3-7": "child_retry", + "3-8": "child_retry_exhaustion", + "3-9": "child_replay", + "3-10": "child_step_wait", + "3-11": "child_large_replay", + "3-12": "child_interrupted", + "3-13": "child_wait_then_step", + "3-14": "child_custom_serdes", + "3-15": "child_direct_error", + "3-16": "child_null", + "3-17": "child_logger_replay", + "3-18": "child_mixed", + "4-1": "callback_basic", + "4-2": "callback_named", + "4-3": "callback_timeout", + "4-4": "callback_heartbeat_timeout", + "4-5": "callback_heartbeat_success", + "4-6": "callback_failure", + "4-7": "callback_step_failure", + "4-8": "callback_step_timeout", + "4-9": "callback_wait_success", + "4-10": "callback_wait_failure", + "4-11": "callback_wait_timeout", + "4-12": "callback_then_wait", + "4-13": "callback_failure_caught", + "4-14": "callback_timeout_caught", + "4-15": "callback_custom_object", + "4-16": "callback_custom_number", + "4-17": "callback_two_sequential", + "4-18": "callback_two_ordered", + "4-19": "callback_two_reverse", + "5-1": "invoke_basic", + "5-2": "invoke_named", + "5-3": "invoke_complex", + "5-4": "invoke_null", + "5-5": "invoke_failure", + "5-6": "invoke_failure_caught", + "5-7": "invoke_large", + "5-8": "invoke_tenant", + "5-9": "invoke_replay", + "5-10": "invoke_replay_failure", + "5-11": "step_then_invoke", + "5-12": "invoke_then_step", + "5-13": "invoke_in_child", + "5-14": "invoke_sequential", + "5-15": "invoke_payload_serdes", + "5-16": "invoke_result_serdes", + "6-1": "wait_for_condition_basic", + "6-2": "wait_for_condition_immediate", + "6-3": "wait_for_condition_named", + "6-4": "wait_for_condition_initial", + "6-5": "wait_for_condition_fixed", + "6-6": "wait_for_condition_exhausted", + "6-7": "wait_for_condition_error", + "6-8": "wait_for_condition_error_caught", + "6-9": "wait_for_condition_object", + "6-10": "wait_for_condition_null", + "6-11": "wait_for_condition_serdes", + "6-12": "wait_for_condition_then_step", + "6-13": "wait_for_condition_sequential", + "7-1": "wait_for_callback_basic", + "7-2": "wait_for_callback_named", + "7-3": "wait_for_callback_anonymous", + "7-4": "wait_for_callback_failure", + "7-5": "wait_for_callback_timeout", + "7-6": "wait_for_callback_failure_caught", + "7-7": "wait_for_callback_submitter_exhausted", + "7-8": "wait_for_callback_child", + "7-9": "wait_for_callback_sequential", + "7-10": "wait_for_callback_mixed", + "7-11": "wait_for_callback_object", + "7-12": "wait_for_callback_heartbeat_timeout", + "7-13": "wait_for_callback_heartbeat_success", + "7-14": "wait_for_callback_timeout_caught", + "7-15": "wait_for_callback_empty", + "8-1": "parallel_basic", + "8-2": "parallel_no_name", + "8-3": "parallel_named", + "8-4": "parallel_heterogeneous", + "8-5": "parallel_empty", + "8-6": "parallel_failfast", + "8-7": "parallel_throw", + "8-8": "parallel_min_successful", + "8-9": "parallel_tolerated", + "8-10": "parallel_tolerated_exceeded", + "8-11": "parallel_concurrent", + "8-12": "parallel_flat", + "8-13": "parallel_pct_exceeded", + "8-14": "parallel_replay", + "8-15": "parallel_serde", + "8-16": "parallel_all_fail", + "8-17": "parallel_min_not_reached", + "8-18": "parallel_combined", + "8-19": "parallel_bad_concurrency", + "8-20": "parallel_accessors", + "8-21": "parallel_nested", + "8-22": "parallel_pct_boundary", + "9-1": "map_basic", + "9-2": "map_items_only", + "9-3": "map_indexed", + "9-4": "map_empty", + "9-5": "map_failfast", + "9-6": "map_throw", + "9-7": "map_min_successful", + "9-8": "map_tolerated", + "9-9": "map_tolerated_exceeded", + "9-10": "map_pct_exceeded", + "9-11": "map_concurrent", + "9-12": "map_flat", + "9-13": "map_named_items", + "9-14": "map_serde", + "9-15": "map_suspend", + "9-16": "map_large", + "9-17": "map_then_wait", + "9-18": "map_fail_then_wait", + "9-19": "map_operation_serde", + "9-20": "map_operation_serde_replay", + "10-1": "plugin_invocation_lifecycle", + "10-2": "plugin_operation_lifecycle", + "10-3": "plugin_attempt_hooks", + "10-4": "plugin_error_isolation", + "10-5": "plugin_multiple", + "10-6": "plugin_first_invocation", + "10-7": "plugin_terminal_failure", + "10-8": "plugin_operation_change", + "10-9": "plugin_external_update", + "10-10": "plugin_wait_lifecycle", + "10-11": "plugin_parent_linkage", + "10-12": "plugin_parallel_functions", + "10-13": "plugin_replay_flags", + "10-14": "plugin_terminal_payloads", + "10-15": "plugin_retry_exhaustion", + "10-16": "plugin_suspension", + "10-17": "plugin_faulty_healthy", + "10-18": "plugin_wait_replay", + "10-19": "plugin_invocation_shape", + "10-20": "plugin_operation_shape", + "10-21": "plugin_attempt_shape", + "10-22": "plugin_change_shape", + "10-23": "plugin_context_shape" + } +} diff --git a/conformance/template.json b/conformance/template.json new file mode 100644 index 0000000..5c39ca2 --- /dev/null +++ b/conformance/template.json @@ -0,0 +1,5874 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": "AWS Durable Execution C++ conformance handlers", + "Resources": { + "DurableFunctionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicDurableExecutionRolePolicy" + ], + "Policies": [ + { + "PolicyName": "ConformanceOperations", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction", + "lambda:SendDurableExecutionCallbackSuccess", + "lambda:SendDurableExecutionCallbackFailure", + "lambda:SendDurableExecutionCallbackHeartbeat" + ], + "Resource": "*" + } + ] + } + } + ] + } + }, + "TargetEcho": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "target_echo" + } + } + } + }, + "TargetError": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "target_error" + } + } + } + }, + "TargetEchoTenant": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "target_echo" + } + }, + "TenancyConfig": { + "TenantIsolationMode": "PER_TENANT" + } + } + }, + "StepBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_basic" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-1" + ], + "NotImplemented": [] + } + }, + "StepNamed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_named" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-2" + ] + } + }, + "StepSequential": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_sequential" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-3" + ] + } + }, + "StepComplex": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_complex" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-4" + ] + } + }, + "StepNull": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_null" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-5" + ] + } + }, + "StepCustomSerdes": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_custom_serdes" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-6" + ] + } + }, + "StepLogger": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_logger" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-7" + ] + } + }, + "StepThenWait": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_then_wait" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-8" + ] + } + }, + "StepReplaySkips": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_replay_skips" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-9" + ] + } + }, + "StepReplayRethrows": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_replay_rethrows" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-10" + ] + } + }, + "StepRetryOnce": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_retry_once" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-11" + ] + } + }, + "StepRetryExhaustion": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_retry_exhaustion" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-12" + ] + } + }, + "StepDefaultRetry": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_default_retry" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-13" + ] + } + }, + "StepCustomRetry": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_custom_retry" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-14" + ] + } + }, + "StepRetrySpecific": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_retry_specific" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-15" + ] + } + }, + "StepNonRetryable": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_non_retryable" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-16" + ] + } + }, + "StepAtMostCrash": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_at_most_crash" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-17" + ] + } + }, + "StepAtMostRetry": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_at_most_retry" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-18" + ] + } + }, + "StepPermanentError": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_permanent_error" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-19" + ] + } + }, + "StepErrorCaught": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_error_caught" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "1-20" + ] + } + }, + "WaitBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_basic" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "2-1" + ] + } + }, + "WaitNamed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_named" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "2-2" + ] + } + }, + "WaitSequential": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_sequential" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "2-3" + ] + } + }, + "WaitMinutes": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_minutes" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "2-4" + ] + } + }, + "WaitHour": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_hour" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "2-5" + ] + } + }, + "ChildBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_basic" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-1" + ] + } + }, + "ChildNamed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_named" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-2" + ] + } + }, + "ChildSequential": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_sequential" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-3" + ] + } + }, + "ChildError": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_error" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-4" + ] + } + }, + "ChildErrorCaught": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_error_caught" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-5" + ] + } + }, + "ChildNested": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_nested" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-6" + ] + } + }, + "ChildRetry": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_retry" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-7" + ] + } + }, + "ChildRetryExhaustion": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_retry_exhaustion" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-8" + ] + } + }, + "ChildReplay": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_replay" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-9" + ] + } + }, + "ChildStepWait": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_step_wait" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-10" + ] + } + }, + "ChildLargeReplay": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_large_replay" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-11" + ] + } + }, + "ChildInterrupted": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_interrupted" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-12" + ] + } + }, + "ChildWaitThenStep": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_wait_then_step" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-13" + ] + } + }, + "ChildCustomSerdes": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_custom_serdes" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-14" + ] + } + }, + "ChildDirectError": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_direct_error" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-15" + ] + } + }, + "ChildNull": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_null" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-16" + ] + } + }, + "ChildLoggerReplay": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_logger_replay" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-17" + ] + } + }, + "ChildMixed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "child_mixed" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "3-18" + ] + } + }, + "CallbackBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_basic" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-1" + ] + } + }, + "CallbackNamed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_named" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-2" + ] + } + }, + "CallbackTimeout": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_timeout" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-3" + ] + } + }, + "CallbackHeartbeatTimeout": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_heartbeat_timeout" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-4" + ] + } + }, + "CallbackHeartbeatSuccess": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_heartbeat_success" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-5" + ] + } + }, + "CallbackFailure": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_failure" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-6" + ] + } + }, + "CallbackStepFailure": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_step_failure" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-7" + ] + } + }, + "CallbackStepTimeout": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_step_timeout" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-8" + ] + } + }, + "CallbackWaitSuccess": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_wait_success" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-9" + ] + } + }, + "CallbackWaitFailure": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_wait_failure" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-10" + ] + } + }, + "CallbackWaitTimeout": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_wait_timeout" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-11" + ] + } + }, + "CallbackThenWait": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_then_wait" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-12" + ] + } + }, + "CallbackFailureCaught": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_failure_caught" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-13" + ] + } + }, + "CallbackTimeoutCaught": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_timeout_caught" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-14" + ] + } + }, + "CallbackCustomObject": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_custom_object" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-15" + ] + } + }, + "CallbackCustomNumber": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_custom_number" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-16" + ] + } + }, + "CallbackTwoSequential": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_two_sequential" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-17" + ] + } + }, + "CallbackTwoOrdered": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_two_ordered" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-18" + ] + } + }, + "CallbackTwoReverse": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "callback_two_reverse" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "4-19" + ] + } + }, + "InvokeBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_basic", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-1" + ] + } + }, + "InvokeNamed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_named", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-2" + ] + } + }, + "InvokeComplex": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_complex", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-3" + ] + } + }, + "InvokeNull": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_null", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-4" + ] + } + }, + "InvokeFailure": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_failure", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-5" + ] + } + }, + "InvokeFailureCaught": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_failure_caught", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-6" + ] + } + }, + "InvokeLarge": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_large", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-7" + ] + } + }, + "InvokeTenant": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_tenant", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEchoTenant.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-8" + ] + } + }, + "InvokeReplay": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_replay", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-9" + ] + } + }, + "InvokeReplayFailure": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_replay_failure", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-10" + ] + } + }, + "StepThenInvoke": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "step_then_invoke", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-11" + ] + } + }, + "InvokeThenStep": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_then_step", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-12" + ] + } + }, + "InvokeInChild": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_in_child", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-13" + ] + } + }, + "InvokeSequential": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_sequential", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-14" + ] + } + }, + "InvokePayloadSerdes": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_payload_serdes", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-15" + ] + } + }, + "InvokeResultSerdes": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "invoke_result_serdes", + "TARGET_FUNCTION_NAME": { + "Fn::Sub": "${TargetEcho.Arn}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + } + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "5-16" + ] + } + }, + "WaitForConditionBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_basic" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-1" + ] + } + }, + "WaitForConditionImmediate": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_immediate" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-2" + ] + } + }, + "WaitForConditionNamed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_named" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-3" + ] + } + }, + "WaitForConditionInitial": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_initial" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-4" + ] + } + }, + "WaitForConditionFixed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_fixed" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-5" + ] + } + }, + "WaitForConditionExhausted": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_exhausted" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-6" + ] + } + }, + "WaitForConditionError": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_error" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-7" + ] + } + }, + "WaitForConditionErrorCaught": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_error_caught" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-8" + ] + } + }, + "WaitForConditionObject": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_object" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-9" + ] + } + }, + "WaitForConditionNull": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_null" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-10" + ] + } + }, + "WaitForConditionSerdes": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_serdes" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-11" + ] + } + }, + "WaitForConditionThenStep": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_then_step" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-12" + ] + } + }, + "WaitForConditionSequential": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_condition_sequential" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "6-13" + ] + } + }, + "WaitForCallbackBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_basic" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-1" + ] + } + }, + "WaitForCallbackNamed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_named" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-2" + ] + } + }, + "WaitForCallbackAnonymous": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_anonymous" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-3" + ] + } + }, + "WaitForCallbackFailure": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_failure" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-4" + ] + } + }, + "WaitForCallbackTimeout": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_timeout" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-5" + ] + } + }, + "WaitForCallbackFailureCaught": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_failure_caught" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-6" + ] + } + }, + "WaitForCallbackSubmitterExhausted": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_submitter_exhausted" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-7" + ] + } + }, + "WaitForCallbackChild": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_child" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-8" + ] + } + }, + "WaitForCallbackSequential": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_sequential" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-9" + ] + } + }, + "WaitForCallbackMixed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_mixed" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-10" + ] + } + }, + "WaitForCallbackObject": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_object" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-11" + ] + } + }, + "WaitForCallbackHeartbeatTimeout": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_heartbeat_timeout" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-12" + ] + } + }, + "WaitForCallbackHeartbeatSuccess": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_heartbeat_success" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-13" + ] + } + }, + "WaitForCallbackTimeoutCaught": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_timeout_caught" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-14" + ] + } + }, + "WaitForCallbackEmpty": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "wait_for_callback_empty" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "7-15" + ] + } + }, + "ParallelBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_basic" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-1" + ] + } + }, + "ParallelNoName": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_no_name" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-2" + ] + } + }, + "ParallelNamed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_named" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-3" + ] + } + }, + "ParallelHeterogeneous": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_heterogeneous" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-4" + ] + } + }, + "ParallelEmpty": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_empty" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-5" + ] + } + }, + "ParallelFailfast": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_failfast" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-6" + ] + } + }, + "ParallelThrow": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_throw" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-7" + ] + } + }, + "ParallelMinSuccessful": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_min_successful" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-8" + ] + } + }, + "ParallelTolerated": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_tolerated" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-9" + ] + } + }, + "ParallelToleratedExceeded": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_tolerated_exceeded" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-10" + ] + } + }, + "ParallelConcurrent": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_concurrent" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-11" + ] + } + }, + "ParallelFlat": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_flat" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-12" + ] + } + }, + "ParallelPctExceeded": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_pct_exceeded" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-13" + ] + } + }, + "ParallelReplay": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_replay" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-14" + ] + } + }, + "ParallelSerde": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_serde" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-15" + ] + } + }, + "ParallelAllFail": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_all_fail" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-16" + ] + } + }, + "ParallelMinNotReached": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_min_not_reached" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-17" + ] + } + }, + "ParallelCombined": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_combined" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-18" + ] + } + }, + "ParallelBadConcurrency": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_bad_concurrency" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-19" + ] + } + }, + "ParallelAccessors": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_accessors" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-20" + ] + } + }, + "ParallelNested": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_nested" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-21" + ] + } + }, + "ParallelPctBoundary": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "parallel_pct_boundary" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "8-22" + ] + } + }, + "MapBasic": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_basic" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-1" + ] + } + }, + "MapItemsOnly": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_items_only" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-2" + ] + } + }, + "MapIndexed": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_indexed" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-3" + ] + } + }, + "MapEmpty": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_empty" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-4" + ] + } + }, + "MapFailfast": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_failfast" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-5" + ] + } + }, + "MapThrow": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_throw" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-6" + ] + } + }, + "MapMinSuccessful": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_min_successful" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-7" + ] + } + }, + "MapTolerated": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_tolerated" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-8" + ] + } + }, + "MapToleratedExceeded": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_tolerated_exceeded" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-9" + ] + } + }, + "MapPctExceeded": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_pct_exceeded" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-10" + ] + } + }, + "MapConcurrent": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_concurrent" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-11" + ] + } + }, + "MapFlat": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_flat" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-12" + ] + } + }, + "MapNamedItems": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_named_items" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-13" + ] + } + }, + "MapSerde": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_serde" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-14" + ] + } + }, + "MapSuspend": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_suspend" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-15" + ] + } + }, + "MapLarge": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_large" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-16" + ] + } + }, + "MapThenWait": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_then_wait" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-17" + ] + } + }, + "MapFailThenWait": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_fail_then_wait" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-18" + ] + } + }, + "MapOperationSerde": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_operation_serde" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-19" + ] + } + }, + "MapOperationSerdeReplay": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "map_operation_serde_replay" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "9-20" + ] + } + }, + "PluginInvocationLifecycle": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_invocation_lifecycle" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-1" + ] + } + }, + "PluginOperationLifecycle": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_operation_lifecycle" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-2" + ] + } + }, + "PluginAttemptHooks": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_attempt_hooks" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-3" + ] + } + }, + "PluginErrorIsolation": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_error_isolation" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-4" + ] + } + }, + "PluginMultiple": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_multiple" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-5" + ] + } + }, + "PluginFirstInvocation": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_first_invocation" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-6" + ] + } + }, + "PluginTerminalFailure": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_terminal_failure" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-7" + ] + } + }, + "PluginOperationChange": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_operation_change" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-8" + ] + } + }, + "PluginExternalUpdate": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_external_update" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-9" + ] + } + }, + "PluginWaitLifecycle": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_wait_lifecycle" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-10" + ] + } + }, + "PluginParentLinkage": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_parent_linkage" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-11" + ] + } + }, + "PluginParallelFunctions": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_parallel_functions" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-12" + ] + } + }, + "PluginReplayFlags": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_replay_flags" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-13" + ] + } + }, + "PluginTerminalPayloads": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_terminal_payloads" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-14" + ] + } + }, + "PluginRetryExhaustion": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_retry_exhaustion" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-15" + ] + } + }, + "PluginSuspension": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_suspension" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-16" + ] + } + }, + "PluginFaultyHealthy": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_faulty_healthy" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-17" + ] + } + }, + "PluginWaitReplay": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_wait_replay" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-18" + ] + } + }, + "PluginInvocationShape": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_invocation_shape" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-19" + ] + } + }, + "PluginOperationShape": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_operation_shape" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-20" + ] + } + }, + "PluginAttemptShape": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_attempt_shape" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-21" + ] + } + }, + "PluginChangeShape": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_change_shape" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-22" + ] + } + }, + "PluginContextShape": { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": [ + "x86_64" + ], + "Handler": "bootstrap", + "CodeUri": "durable_execution_conformance.zip", + "Timeout": 60, + "MemorySize": 256, + "Role": { + "Fn::GetAtt": [ + "DurableFunctionRole", + "Arn" + ] + }, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900 + }, + "Environment": { + "Variables": { + "CONFORMANCE_CASE": "plugin_context_shape" + } + } + }, + "TestingMetadata": { + "TestDescription": [ + "10-23" + ] + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9a9d3d7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,81 @@ +# Architecture + +The SDK is split into three layers so AWS integration and JSON parsing do not +leak into the replay hot path. + +1. **Core** — protocol values, deterministic identity, replay context, + checkpoint state, operations, serializers, and handler result mapping. +2. **Service adapter** — implements `service_client` using the AWS SDK for C++ + Lambda durable APIs. One virtual dispatch occurs per network request, not per + local model or serializer operation. +3. **Runtime adapter** — translates Lambda invocation JSON/context into + `invocation_input`, attaches function/tenant metadata, calls `run`, and + writes the invocation response. +4. **Local runner** — implements the same `service_client` boundary in memory, + advances a virtual clock, and repeatedly invokes the unchanged handler. + +The service and runtime adapters are optional build targets. Applications that +use a custom transport or embed the core do not link either AWS dependency. + +## Replay flow + +```text +Lambda invocation + -> runtime adapter + -> execution_state loads all history pages + -> durable_context reserves deterministic operation IDs + -> operation finds checkpoint by ID + succeeded: deserialize and skip user code + pending: suspend invocation + absent/ready: execute and checkpoint + incompatible: fail closed + -> child contexts repeat the same flow in an ID-prefixed namespace + -> parallel/map reserve every branch ID before bounded workers start + -> contending branch checkpoints are flat-combined into atomic API batches + -> flow validates its DAG, prunes unreachable nodes, then pre-reserves every + reachable node context before scheduling ready waves + -> handler returns SUCCEEDED, PENDING, FAILED, or RETRY +``` + +## Performance rules + +- No dependency is required by the core library. +- Invocation JSON is parsed directly into protocol models. Unknown values are + skipped recursively without allocating a general-purpose DOM. +- Recursive-level inspection and replacement use a targeted top-level JSON + object scanner that preserves all unrelated value slices. +- History lookup accepts `std::string_view`, performs no temporary string + allocation, and returns an immutable shared snapshot safe across concurrent + state refreshes. +- Known wire enums store only an enum value; storage is allocated only for an + unknown future value. +- Serializers and durable callables are templates, allowing inlining. +- Declarative flow uses type erasure only at graph boundaries. Ordinary step, + callback, map, and invoke hot paths remain statically dispatched. +- The local runner uses no scheduler thread and performs no sleeps. Timer + transitions advance directly to the next virtual event. +- Exceptions represent invocation control flow and failures; ordinary transport + results use `std::expected`. +- Callback and chained-invoke results use `std::optional` to represent a + successful backend operation without a payload. +- Sequential checkpoints use a direct call path. Parallel/map scopes enable a + flat-combining queue only while workers are active, avoiding queue and + background-thread overhead for ordinary workflows. +- Benchmarks must retain their checksum and run in optimized builds to prevent + dead-code elimination. + +## Safety invariants + +- User code inside a durable step cannot create nested durable operations. +- At-most-once interrupted steps never rerun the same attempt. +- A replay identity mismatch fails before user code executes. +- Unknown future statuses fail closed when their semantics are unsafe. +- A missing checkpoint token is accepted only after terminal execution updates. +- Callback failures are deferred from creation to `callback_handle::result()` so + replay always executes code between those two boundaries. +- A suspended operation inside a child context never writes a false child + failure checkpoint. +- Checkpoint-token mutation is serialized, and history entries are immutable + snapshots replaced atomically under a write lock. +- Parallel workers operate on forked durable contexts whose operation counters + were pre-reserved on the caller thread. diff --git a/docs/aws-integration.md b/docs/aws-integration.md new file mode 100644 index 0000000..29e9281 --- /dev/null +++ b/docs/aws-integration.md @@ -0,0 +1,75 @@ +# AWS integration + +The SDK separates durable workflow behavior from AWS client/runtime ownership. +This keeps the core usable in local runners and lets applications control AWS +configuration, credentials, HTTP clients, executors, logging, and shutdown. + +## AWS SDK for C++ transport + +Configure with: + +```console +cmake -S . -B build \ + -DDURABLE_EXECUTION_BUILD_AWS_SDK_ADAPTER=ON +``` + +Link `aws::durable_execution_aws_sdk` and construct +`aws_sdk_service_client` from either: + +- a non-owning `const Aws::Lambda::LambdaClient&`; or +- an owning `std::shared_ptr`. + +The adapter maps: + +- `CheckpointDurableExecutionRequest` and its updated execution state; +- `GetDurableExecutionStateRequest` pagination; +- every currently defined operation details/options model; +- AWS SDK retryability into `service_error::retryable`; +- future AWS enum values through the SDK enum-overflow mapper into + `extensible_enum`. + +The application must call `Aws::InitAPI` before constructing the AWS client and +`Aws::ShutdownAPI` after all SDK objects are destroyed. + +## Lambda C++ runtime + +Configure with: + +```console +cmake -S . -B build \ + -DDURABLE_EXECUTION_BUILD_LAMBDA_RUNTIME_ADAPTER=ON +``` + +Link `aws::durable_execution_lambda_runtime`. `make_lambda_handler` returns a +callable accepted by `aws::lambda_runtime::run_handler`. + +Malformed durable invocation envelopes are returned as Lambda invocation +failures with type `DurableExecutionWireError`. Valid durable results—including +durable statuses `FAILED`, `PENDING`, and `RETRY`—are successful Lambda runtime +responses because their status belongs to the durable protocol response body. + +## Complete entry point + +See [the Lambda example](../examples/lambda_main.cpp). In outline: + +```cpp +Aws::SDKOptions options; +Aws::InitAPI(options); +{ + auto client = std::make_shared(); + aws::durable_execution::aws_sdk_service_client service{client}; + auto handler = aws::durable_execution::make_lambda_handler( + service, + [](std::string_view event) { + return aws::durable_execution::step([event] { + return event.empty() ? 0 : 1; + }); + }); + aws::lambda_runtime::run_handler(handler); +} +Aws::ShutdownAPI(options); +``` + +The SDK does not hide the AWS lifecycle in a global singleton. Explicit +ownership prevents shutdown-order bugs and lets test code inject a +`service_client` without initializing AWS. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..eaf55b4 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,143 @@ +# Compatibility policy + +Compatibility is a release gate for this SDK. A change is incomplete until its +effect on existing binaries, source code, durable histories, wire payloads, and +serialized user values has been considered and tested. + +## Versioning dimensions + +### Source compatibility + +The project follows semantic versioning. Minor releases may add overloads, +fields with safe defaults, enum values, or new operations. They must not remove +or reinterpret existing public names. Removals and incompatible signature +changes require a major release. + +The minimum language level is C++23. Raising it requires a major release unless +the currently selected standard has not changed. + +### ABI compatibility + +Public symbols are emitted in `aws::durable_execution::v1`, exposed as an inline +namespace so normal source code continues to use `aws::durable_execution`. +Shared-library builds use `SOVERSION 1`. A breaking ABI starts a new inline +namespace and SONAME. + +Header-only templates are governed primarily by source compatibility; exported +non-template symbols are governed by both source and ABI compatibility. +Binary compatibility assumes the same platform, architecture, compiler ABI, +standard library ABI, build mode, and compatible compile definitions. The +versioned namespace does not claim interoperability between incompatible C++ +toolchains. + +### Durable-history compatibility + +Operation identity is part of persisted history and is therefore immutable: + +- sequential identities use BLAKE2b-512 and the first 64 hexadecimal characters; +- prefixed identities hash `-`; +- local identities hash `local:` within the prefix. + +Published golden vectors lock the exact digest and truncation behavior. The +fixture generator uses Python's `hashlib` as an independent oracle, not as a +cross-language compatibility requirement. + +Changing operation order, name, subtype, parent, or type is detected during +replay. The SDK fails closed rather than silently executing a different +operation against old history. + +### Wire compatibility + +Wire readers must: + +- accept omitted optional fields and apply documented defaults; +- ignore JSON object members unknown to the current SDK; +- preserve unknown enum values through `extensible_enum`; +- reject an unknown state only when interpreting it could rerun side effects or + corrupt history. + +Wire writers emit the oldest representation that expresses the requested +behavior. New optional fields are omitted when they hold their compatibility +default. + +`operation_type` and `operation_status` are open on input. Known values require +no dynamic allocation; unknown future values retain their original wire text. +Operation subtypes are strings so third-party and future AWS operations can be +represented without an SDK release. + +Extension-owned subtype strings and locally reserved operation IDs are durable +history. Once deployed, their spelling and identity derivation must remain +stable. SDK-owned subtype tokens are rejected by the extension SPI to prevent a +third-party operation from masquerading as a built-in primitive. + +`UpdatedOperationIds` is optional on invocation input. Older runtimes that omit +it produce an empty updated-operation set; newer runtimes retain exact IDs for +plugin replay/change notifications without changing operation history. + +### Serialized value compatibility + +Built-in primitive serializers use standard JSON scalar representations. Their +wire form is stable within ABI major version 1. Custom domain serializers are +owned and versioned by the application. Applications should include a schema +version when a domain payload may evolve. + +`optional_serdes` uses a version-1 presence envelope: + +- absent: `{"p":false}` +- present: `{"p":true,"v":""}` + +The inner payload is quoted so custom serializers may emit arbitrary text. +Future built-in container/type envelopes must add tags rather than redefine +existing fields and must retain decoders for every previously released form. + +`batch_result` payloads begin with the line `DEXBR1`. Each following field is a +length-delimited record, so arbitrary custom serializer output can be embedded +without escaping ambiguity. New readers must retain support for `DEXBR1`; an +incompatible layout requires a new magic/version prefix. + +Replay-safe UTC time is normalized to Unix milliseconds before its first +checkpoint, ensuring the value returned initially is identical to replay. +`uuid_value` uses the canonical lowercase RFC 4122 text form. These payload +forms are stable within ABI major version 1. + +The reserved recursive input field is `__recursive_level`. Rewriters remove any +existing top-level occurrence and append one integer field, while preserving +the raw JSON slices of every unrelated member. + +Flow node records begin with `DEXFN1`; complete flow results begin with +`DEXFL1`. The flow result stores node names and selected projection descriptors +and validates both against the current frozen definition during replay. +Incompatible node-record or flow-result layouts require new magic/version +prefixes while retaining decoders for these version-1 forms. + +The local runner executes the same public protocol models and handler entry +point as AWS. It does not define a separate history wire format; operation and +update snapshots remain the authoritative test representation. + + + +## Deprecation + +Before 1.0, incompatible changes may still be necessary, but they must be +called out in release notes and accompanied by migration guidance. Starting at +1.0, a public API is deprecated for at least one minor release before removal +in the next major release. + +## Required compatibility tests + +The release suite must cover: + +- golden operation-ID vectors; +- replay of histories written by every supported minor release; +- missing optional fields and unknown extra fields; +- unknown operation type/status/subtype values; +- CMake consumption through the installed package; +- ABI symbol/SONAME checks for stable releases; +- independent protocol and serializer oracle fixtures. + +The official AWS conformance manifest is also a compatibility inventory. Every +requirement ID must remain mapped, strict generation must report `171/171`, and +a change to an existing handler must be reviewed for both fresh execution and +replay against previously persisted history. Adding support for a newer +optional wire field must not make that field mandatory when reading older +history. diff --git a/docs/concurrency.md b/docs/concurrency.md new file mode 100644 index 0000000..f46d95e --- /dev/null +++ b/docs/concurrency.md @@ -0,0 +1,67 @@ +# Concurrency and checkpoint batching + +Parallel execution uses deterministic reservation plus bounded native workers. +It does not share a mutable `durable_context` between threads. + +## Deterministic branch identity + +Before workers start, the caller context reserves every branch or map-iteration +operation in input order. Each worker receives a forked context positioned +immediately before its reserved index. Scheduling order therefore cannot affect +operation IDs, names, subtypes, or parent IDs. + +## Worker scheduling + +`max_concurrency` sets the number of `std::jthread` workers. A value of zero +uses `std::thread::hardware_concurrency()` with a fallback of four workers. + +A completed or failed branch releases its worker to process the next branch. A +suspended branch retains its logical slot: that worker exits instead of +starting another item. On the next durable invocation, replayed completed +branches return quickly and suspended branches occupy the same bounded prefix +of work. + +## Early completion + +Threshold and custom completion policies stop assigning new work. Items that +never started, or remained suspended when the parent completed early, are +recorded as cancelled in the aggregate result. + +C++ cannot safely terminate arbitrary synchronous user code. A branch already +executing runs to its next normal return, exception, or durable suspension, and +the executor joins it before returning. Callables that need cooperative +cancellation should implement it explicitly in application code. + +## Checkpoint combining + +`execution_state::enable_checkpoint_batching()` activates an RAII scope. Outside +that scope, checkpoints call the service directly. + +Inside the scope: + +1. the first contending caller becomes the processor; +2. other callers append immutable update copies and wait; +3. the processor groups requests up to 250 operations and approximately + 750 KiB by default; +4. one service request consumes the current checkpoint token; +5. immutable operation snapshots are published before waiting callers resume. + +The default 100-microsecond coalescing window applies only inside an enabled +batching scope. It trades negligible local delay against fewer network calls. +Limits and the delay are configurable through `checkpoint_batcher_config`. + +Any batch failure is propagated to every waiting request. The batching scope is +normally owned by the parallel executor and ends only after all worker calls +have returned. + +## Thread-safety boundary + +The SDK guarantees concurrent safety for: + +- operation history lookup and replacement; +- checkpoint token consumption; +- service calls issued through one `execution_state`; +- built-in parallel/map result aggregation. + +Application callables and custom serializers invoked concurrently must either +be immutable or provide their own synchronization. diff --git a/docs/conformance.md b/docs/conformance.md new file mode 100644 index 0000000..508a60c --- /dev/null +++ b/docs/conformance.md @@ -0,0 +1,134 @@ +# Reference fixtures and AWS conformance + +## Independent oracle fixtures + +Fixtures under `tests/fixtures` are generated with the referenced Python SDK as +an independent implementation oracle: + +- `python_invocation.json` covers every AWS operation-details model consumed by + the invocation wire reader; +- `python_operation_ids.tsv` locks sequential, prefixed, and local-ID hashing; +- `python_serdes.tsv` locks primitive, UUID, and UTC datetime payloads. + +Regenerate them with an environment containing `async-durable-execution`: + +```console +python scripts/generate_cross_language_fixtures.py +``` + +The regular C++ test executable reads these files directly. They catch +accidental protocol, hashing, UUID, and datetime drift. They do not make +cross-language application compatibility part of the C++ SDK contract. + +## Official suite baseline + +`conformance/supported.json` is pinned to official conformance commit +`02d6dca971a38c13d94d6233d12f687e55b2a572` from August 25, 2026. +That revision contains 171 requirements: 148 core requirements plus 23 +instrumentation-plugin requirements across: + +- step; +- wait; +- child; +- callback; +- invoke; +- wait-for-condition; +- wait-for-callback; +- parallel; +- map; +- plugin. + +The deployable mapping covers all 171 requirements. Strict template generation +passes with zero `NotImplemented` entries. + +`scripts/validate_conformance_assets.py` verifies that every official ID is +exactly one of covered or explicitly unsupported. + +## Local handler validation + +`durable_execution_conformance_local_tests` runs the complete mapped handler +matrix against `local_runner`, including retries, replay, external callbacks, +mocked chained invokes, nested child contexts, polling, parallel, and map. +Plugin handlers are additionally checked with the official CloudWatch log +matcher, including log cardinality and ordering constraints. +Crash/restart requirements are represented by deployable handlers; destructive +process-exit behavior is not executed inside the local test process. + +## Generate the SAM template + +After cloning the official requirements repository: + +```console +python scripts/generate_conformance_template.py \ + --requirements-dir /path/to/aws-durable-execution-conformance-tests/packages/aws-durable-execution-conformance-tests/test-requirements +``` + +Use `--strict` as a release gate. It must report `171/171` and zero unsupported +requirements. + +With the official validator installed, validate plugin logs locally with: + +```console +python scripts/validate_plugin_logs.py \ + --requirements-dir /path/to/aws-durable-execution-conformance-tests/packages/aws-durable-execution-conformance-tests/test-requirements \ + --runner build/durable_execution_conformance_local_tests +``` + +## Build the custom runtime + +Install the AWS SDK for C++ Lambda component and `aws-lambda-cpp`, then configure: + +```console +cmake -S . -B build-conformance \ + -DCMAKE_BUILD_TYPE=Release \ + -DDURABLE_EXECUTION_BUILD_AWS_SDK_ADAPTER=ON \ + -DDURABLE_EXECUTION_BUILD_LAMBDA_RUNTIME_ADAPTER=ON \ + -DDURABLE_EXECUTION_BUILD_CONFORMANCE=ON + +cmake --build build-conformance \ + --target durable_execution_conformance \ + --parallel +``` + +When available, `aws_lambda_package_target` produces +`durable_execution_conformance.zip`. Place it beside the generated +`template.json` or regenerate with a matching `--code-uri`. + +For a reproducible x86-64 Lambda build on Amazon Linux 2023, provide pinned +source checkouts and run: + +```console +AWS_SDK_CPP_SOURCE=/path/to/aws-sdk-cpp \ +AWS_LAMBDA_CPP_SOURCE=/path/to/aws-lambda-cpp \ +scripts/build_conformance_al2023.sh +``` + +The script installs only build tools inside an ephemeral AL2023 container, +builds the Lambda-only AWS SDK component statically, validates the resulting +ZIP, and writes `conformance/durable_execution_conformance.zip`. Its build +cache defaults to `.cache/conformance-al2023` and can be relocated with +`BUILD_ROOT`. + +## Run against AWS + +With AWS credentials and SAM CLI configured: + +```console +durable-execution-conformance \ + --template conformance/template.json \ + --language cpp \ + --region us-west-2 \ + --suite step wait child callback invoke wait_for_condition wait_for_callback parallel map plugin \ + --report console json junit \ + --fail-on failed +``` + +Cloud execution runs in GitHub Actions through the repository integration-test +role. Local passing results remain a fast preflight and do not substitute for +deployed history validation. + +The repository CI uses a fixed `conformance-tests-cpp-persistent` stack and a +non-cancelling concurrency group. Each run updates that stack in place and +passes `--no-cleanup`, avoiding repeated creation/deletion of the full Lambda +matrix while ensuring only one conformance run mutates or exercises it at a +time. diff --git a/docs/custom-operations.md b/docs/custom-operations.md new file mode 100644 index 0000000..1437177 --- /dev/null +++ b/docs/custom-operations.md @@ -0,0 +1,69 @@ +# Custom durable operations + +The extension SPI lets libraries compose SDK primitives under their own +operation subtypes without depending on internal implementation details. + +Obtain the active extension context and reserve identities before executing +the operations: + +```cpp +auto extension = aws::durable_execution::get_extension_context(); +auto load = extension.reserve("load"); +auto pause = extension.reserve( + "pause", std::string_view{"stable-pause-id"}); + +const auto value = load.step( + [] { return load_from_service(); }, + "AcmeLoad"); +pause.wait(std::chrono::seconds{5}, "AcmePause"); +``` + +Reservations are move-only and one-shot. Sequential reservations preserve +their IDs even when execution order changes. A `local_operation_id` derives an +ID independent of reservation order and must be unique within its durable +context. + +Custom subtype strings must be nonblank and must not reuse SDK-owned subtype +tokens such as `Step`, `Wait`, or `RunInChildContext`. + +## Supported primitives + +`extension_operation` delegates to the same implementation used by normal SDK +operations: + +- `step` +- `stateful_step` +- `wait` +- `invoke` +- `create_callback` +- `run_in_child_context` + +This preserves retry, replay, checkpoint, serialization, plugin, and +parent-child behavior while substituting the pre-reserved identity and custom +subtype. + +Stateful steps return `extension_step_result`: + +```cpp +const int final_state = extension.reserve("poll").stateful_step( + [](const std::optional& state) { + const int current = state.value_or(0); + return current < 3 + ? aws::durable_execution::extension_step_result::retry( + current + 1, std::chrono::seconds{1}) + : aws::durable_execution::extension_step_result::succeed( + current); + }, + "AcmePoll", + 0); +``` + +The state is serialized into each retry checkpoint and restored on the next +invocation. An optional exception retry strategy may replace the state before +retrying. + +## Context safety + +A reservation can only be claimed in the durable context where it was created. +Using a captured extension context or reservation from inside a durable step is +rejected because durable operations cannot be nested inside step user code. diff --git a/docs/local-runner.md b/docs/local-runner.md new file mode 100644 index 0000000..00bbb49 --- /dev/null +++ b/docs/local-runner.md @@ -0,0 +1,79 @@ +# Deterministic local runner + +The local runner executes durable handlers in process using +`local_service_client`, a protocol-level implementation of `service_client`. +Production workflow code is unchanged. + +## Virtual time + +The runner never sleeps. When an invocation returns `PENDING`, it advances to +the earliest automatic event: + +- WAIT completion; +- STEP retry becoming READY; +- a mocked chained-invoke result; +- execution timeout; +- optionally, callback timeout or heartbeat timeout. + +All events due at the selected virtual timestamp are applied before the next +invocation. `local_test_result::virtual_time()` exposes the resulting clock. + +Callbacks stop execution with `pending_external` by default, allowing tests to +inspect the generated callback ID and respond: + +```cpp +auto pending = runner.run(); +auto id = pending.pending_callback_ids().front(); +runner.send_callback_success(id, serialized_result); +auto complete = runner.resume(); +``` + +Use `advance_time()` to test callback deadlines manually, or set +`auto_advance_callback_timeouts=true`. + +## Chained invoke mocks + +Register serialized results before running: + +```cpp +runner.mock_invoke_success("worker:prod", R"({"status":"ok"})"); +``` + +Unmocked chained invokes produce `pending_external`. Failure mocks accept an +`error_object`. + +## Result statuses + +`local_run_status` distinguishes: + +- `succeeded`; +- `failed`; +- `pending_external`; +- `deadlocked` — the handler returned pending without a resumable operation; +- `invocation_limit_exceeded`; +- `timed_out`. + +The invocation limit protects tests from nondurable retry loops. + +## History inspection + +Each `local_test_result` owns immutable copies of: + +- final invocation output; +- operation history; +- checkpoint updates; +- invocation count; +- virtual time. + +Convenience lookups include `operation_by_id`, `operation_by_name`, `step`, +`wait`, `pending_callback_ids`, and typed `deserialize_result`. + +## Fidelity boundaries + +The runner validates checkpoint tokens, operation identity, and lifecycle +transitions. It supports batching and parallel calls because its service +implementation is mutex-protected. + +It intentionally omits AWS networking, IAM, service throttling, retention, and +distributed callback delivery. Those behaviors belong to deployed integration +and conformance tests. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..5f01d20 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,193 @@ +# Durable operations + +All operations reserve deterministic IDs in call order. Replaying the same +history with a changed type, subtype, name, or parent fails before user code is +executed. + +## Step + +`step` isolates nondeterministic work. A successful checkpoint returns the +deserialized historical result without rerunning the callable. + +- `at_least_once_per_retry` may rerun an interrupted attempt. +- `at_most_once_per_retry` schedules a new retry or fails rather than rerunning + an interrupted attempt. + +## Wait + +`wait` writes a timer checkpoint and suspends. It returns only after replay +observes `SUCCEEDED`. + +## Callback + +`create_callback` writes a callback checkpoint and returns +`callback_handle`. Creation only validates that callback details and an +ID exist. Terminal callback errors are intentionally deferred until +`callback_handle::result()` so code between creation and result retrieval runs +on both original execution and replay. + +`result()` returns `std::optional`: + +- a payload produces a value through the selected serializer; +- successful completion without a payload produces `std::nullopt`; +- incomplete callbacks suspend; +- failed, cancelled, timed-out, or stopped callbacks throw `callback_error`. + +The default string callback serializer is pass-through because callback success +APIs already provide a serialized string payload. Supply a serializer for typed +results. + +## Wait for callback + +`wait_for_callback` creates a non-virtual child context containing: + +1. a callback operation; +2. a durable submitter step; +3. callback result retrieval. + +The submitter accepts `std::string_view callback_id` or no arguments. Passing +the ID directly is explicit, testable, and avoids an additional thread-local +context API. + +## Chained invoke + +`invoke` serializes its payload, starts a `CHAINED_INVOKE`, and suspends. +On replay it returns `std::optional`, or throws `callable_error` for a +terminal failure. A function name or ARN and optional tenant ID are recorded in +the start checkpoint. + +## Child context + +`run_in_child_context` creates a `CONTEXT` operation and executes nested durable +operations in a namespace prefixed by the child operation ID. This prevents +collisions between identical child workflows. + +Results up to 256 KiB are checkpointed normally. Larger results set +`ReplayChildren`; an optional summary generator supplies the compact checkpoint +payload, and replay re-executes the child operations to reconstruct the full +result. + +Virtual contexts use the same prefixed ID namespace but omit context lifecycle +checkpoints. Their child operations are flattened under the current parent. +Changing an observable context between virtual and non-virtual mode fails +closed when prior history makes the mismatch detectable. + +## Parallel + +`parallel` accepts either a random-access range of homogeneous callables or a +tuple of heterogeneous callable types with a common result type. Branch IDs are +reserved on the caller thread before any worker starts, so scheduling order +cannot change durable history identities. + +Each nested branch has subtype `ParallelBranch`. Flat nesting omits branch +context lifecycle checkpoints while retaining its prefixed ID namespace. +`batch_result` records successful, failed, cancelled, and still-started items. + +## Map + +`map` copies the input range into stable storage, reserves one deterministic +`MapIteration` branch per item, and invokes the item function through the same +parallel executor. `max_concurrency` limits worker slots. + +## Completion policies + +`completion_config` supports: + +- minimum successful count; +- tolerated failure count; +- first successful; +- all successful/all completed; +- a deterministic custom decision callback. + +Early completion prevents unscheduled branches from starting and records them +as cancelled. Synchronous C++ code already running on another worker cannot be +forcibly interrupted safely, so workers already executing are joined before +the aggregate result is returned. + +## Wait for condition + +`wait_for_condition` stores the latest state in the STEP retry payload. +The check receives optional current state and optionally the one-based attempt. +A polling strategy returns the next delay or `std::nullopt` to complete. + +Pending checkpoints suspend without running the check. READY checkpoints +restore state, increment the attempt, and execute the next check. Exhaustion and +checker exceptions are checkpointed as terminal failures. + +## With retry + +`with_retry` wraps an entire durable block in a child context. The body accepts +the one-based attempt or no arguments. A failed attempt creates a named durable +wait before reconstructing the next attempt during replay. + +Durable suspension, checkpoint failures, state-fetch failures, serialization +errors, and replay-identity errors are control failures and are not treated as +retryable body errors. Applications can supply `retry_decider` to filter other +exceptions. + +## Replay-safe values + +The `replay_safe` namespace provides checkpointed: + +- uniform random doubles; +- millisecond-normalized UTC `timestamp` values; +- Unix timestamps in seconds; +- RFC 4122 version-4 `uuid_value` values. + +Each helper is a normal durable step, so replay returns exactly the checkpointed +value without calling the clock or entropy source again. + +## Recursive invoke + +`recurse` and `recurse_json` build on chained invoke. The target is resolved +from an explicit function name or Lambda invocation metadata. Unqualified +runtime names are combined with the function version when available. + +Recursive payloads must differ from the current execution input. +`with_recursive_level` requires a JSON object and replaces +`__recursive_level` with the current level plus one. Tenant metadata is +propagated unless explicitly overridden. + +## Declarative flow + +`flow_builder` creates typed node handles without executing user code. Calling +`flow` freezes and validates the definition before writing the top-level +checkpoint. + +Validation rejects: + +- blank or duplicate node names; +- self and duplicate dependencies; +- nodes or outputs from another builder; +- cycles; +- mutation after freeze. + +Only nodes reachable by reverse dependency traversal from selected outputs are +executed. Other nodes appear as skipped without consuming operation IDs. + +Dependencies are immutable expressions: + +- `node.succeeded()` requires a successful result; +- `node.failed()` routes failure and marks it handled; +- `node.completed()` matches every settled logical status without handling a + failure; +- `&&` requires every child expression; +- `||` requires one child expression. + +ANY expressions choose the first matching child in expression order from the +currently settled durable results. This deterministic rule avoids +completion-order drift between native threads and replay. + +Ready nodes execute concurrently in pre-reserved child contexts. Node callables +receive `flow_node_context&` or no arguments. The context provides typed, +freshly deserialized snapshots of direct dependency outcomes, results, and +errors, preventing mutable values from leaking between consumers. + +Logical node exceptions become `flow_node_status::failed` records and can +activate failure routes. Durable suspension and SDK control failures propagate +without being misclassified as logical failures. + +`flow_result` supports typed node lookup and selected outcome, error, or full +result projections. Unhandled failures and unavailable outcome projections +raise `flow_execution_error` only after the complete flow result has been +checkpointed. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..15d710d --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,59 @@ +# Instrumentation plugins + +Instrumentation plugins observe durable execution without changing workflow +semantics. The contract is versioned by +`instrumentation_plugin_api_version`, currently `1`. + +Derive from `instrumentation_plugin` and override only the hooks needed: + +```cpp +class metrics_plugin final + : public aws::durable_execution::instrumentation_plugin { + public: + void on_operation_end( + const aws::durable_execution::operation_info& info) override { + record_operation(info.type, info.status.value_or("UNKNOWN")); + } +}; +``` + +Register plugins through `run_options`: + +```cpp +aws::durable_execution::run_options options{ + .plugins = {std::make_shared()}, +}; + +auto handler = aws::durable_execution::make_lambda_handler( + service, durable_function, + aws::durable_execution::default_serdes{}, + std::move(options)); +``` + +## Hook model + +The API exposes: + +- invocation start/end, including request ID, execution ARN, input, full + operation snapshots, updated operations, first-invocation state, terminal + status, result, and error; +- operation start/end with name, type, subtype, parent, status, timestamps, + checkpointed result/error, attempt, and replay flags; +- user-function attempt start/end, including outcome and context-children + replay state; +- operation-change notifications containing both the updated delta and full + operation snapshot. + +`UpdatedOperationIds` from the Lambda invocation identifies operations changed +externally between invocations, such as completed waits, callbacks, and chained +invokes. The field is optional for backward compatibility. + +## Safety and performance + +Every plugin callback is isolated with a catch-all boundary. A plugin exception +is swallowed and remaining plugins still run. Hooks execute synchronously so +records are complete before the Lambda response is returned. + +When no plugins are configured, lifecycle paths short-circuit before allocating +plugin snapshots. Plugins may be called concurrently by parallel/map worker +threads and must synchronize mutable state they share. diff --git a/examples/lambda_main.cpp b/examples/lambda_main.cpp new file mode 100644 index 0000000..020e92c --- /dev/null +++ b/examples/lambda_main.cpp @@ -0,0 +1,37 @@ +#include +#include +#include + +#include +#include +#include + +#include "aws/durable_execution/aws_sdk_service_client.hpp" +#include "aws/durable_execution/lambda_runtime.hpp" +#include "aws/durable_execution/operations.hpp" + +namespace durable = aws::durable_execution; + +int main() { + Aws::SDKOptions options; + Aws::InitAPI(options); + { + auto lambda_client = std::make_shared(); + durable::aws_sdk_service_client service{lambda_client}; + + auto handler = durable::make_lambda_handler( + service, [](std::string_view event) { + return durable::step( + [event] { + // Replace with nondeterministic I/O or business logic. + return event.empty() ? std::string{"empty"} + : std::string{"processed"}; + }, + durable::step_config{.name = "process_input"}); + }); + + aws::lambda_runtime::run_handler(handler); + } + Aws::ShutdownAPI(options); + return 0; +} diff --git a/examples/order_workflow.cpp b/examples/order_workflow.cpp new file mode 100644 index 0000000..8a79ca1 --- /dev/null +++ b/examples/order_workflow.cpp @@ -0,0 +1,30 @@ +#include +#include +#include + +#include "aws/durable_execution/durable_execution.hpp" + +namespace durable = aws::durable_execution; + +class example_service_client final : public durable::service_client { + public: + std::expected checkpoint( + const durable::checkpoint_request&) override { + return std::unexpected(durable::service_error{ + .message = + "Bind a real AWS Lambda durable service adapter before invoking this example", + .retryable = false, + }); + } + + std::expected + get_execution_state(const durable::get_state_request&) override { + return durable::state_output{}; + } +}; + +int main() { + std::cout + << "The SDK core is ready. Supply a service_client implementation and call " + "aws::durable_execution::run(...) from the Lambda runtime adapter.\n"; +} diff --git a/include/aws/durable_execution/aws_sdk_service_client.hpp b/include/aws/durable_execution/aws_sdk_service_client.hpp new file mode 100644 index 0000000..e13e6a6 --- /dev/null +++ b/include/aws/durable_execution/aws_sdk_service_client.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "aws/durable_execution/service_client.hpp" + +namespace Aws::Lambda { +class LambdaClient; +} + +namespace aws::durable_execution { +inline namespace v1 { + +class aws_sdk_service_client final : public service_client { + public: + explicit aws_sdk_service_client( + const Aws::Lambda::LambdaClient& client) noexcept; + explicit aws_sdk_service_client( + std::shared_ptr client); + + [[nodiscard]] std::expected checkpoint( + const checkpoint_request& request) override; + + [[nodiscard]] std::expected + get_execution_state(const get_state_request& request) override; + + private: + std::shared_ptr owned_client_; + const Aws::Lambda::LambdaClient* client_; +}; + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/callback.hpp b/include/aws/durable_execution/callback.hpp new file mode 100644 index 0000000..99c54de --- /dev/null +++ b/include/aws/durable_execution/callback.hpp @@ -0,0 +1,183 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/execution_state.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct callback_config { + std::optional name; + std::chrono::seconds timeout{0}; + std::chrono::seconds heartbeat_timeout{0}; +}; + +namespace detail { + +template +struct callback_default_serdes { + using type = default_serdes; +}; + +template <> +struct callback_default_serdes { + using type = passthrough_serdes; +}; + +template +using callback_default_serdes_t = + typename callback_default_serdes::type; + +[[nodiscard]] inline std::uint32_t callback_seconds( + std::chrono::seconds value, std::string_view field) { + if (value.count() < 0 || + static_cast(value.count()) > + std::numeric_limits::max()) { + throw durable_error( + error_code::invalid_argument, + std::string{field} + " is outside the supported range"); + } + return static_cast(value.count()); +} + +[[nodiscard]] inline std::string callback_failure_message( + const operation& existing) { + const error_object* error = + existing.callback && existing.callback->error + ? &*existing.callback->error + : nullptr; + if (!error || !error->message) { + return "Callback failed"; + } + std::string message = *error->message; + if (existing.status == operation_status::timed_out && error->type && + (*error->type == "Callback.Timeout" || + *error->type == "Callback.Heartbeat") && + message.find(*error->type) == std::string::npos) { + message.append(": "); + message.append(*error->type); + } + return message; +} + +} // namespace detail + +template < + typename Result = std::string, + typename Serializer = detail::callback_default_serdes_t> + requires serializer_for +class callback_handle { + public: + callback_handle( + std::string callback_id, std::string operation_id, + execution_state& state, Serializer serializer) + : callback_id_(std::move(callback_id)), + operation_id_(std::move(operation_id)), + state_(&state), + serializer_(std::move(serializer)) {} + + [[nodiscard]] const std::string& callback_id() const noexcept { + return callback_id_; + } + + [[nodiscard]] const std::string& operation_id() const noexcept { + return operation_id_; + } + + [[nodiscard]] std::optional result() const { + const auto existing = state_->find_operation(operation_id_); + if (!existing) { + throw callback_error( + "Callback operation must exist", callback_id_); + } + if (!existing->status.is_known()) { + throw callback_error( + "Callback has an unknown future status: " + + std::string{existing->status.wire_value()}, + callback_id_); + } + + if (existing->status == operation_status::failed || + existing->status == operation_status::cancelled || + existing->status == operation_status::timed_out || + existing->status == operation_status::stopped) { + throw callback_error( + detail::callback_failure_message(*existing), callback_id_); + } + if (existing->status == operation_status::succeeded) { + if (!existing->callback || !existing->callback->result) { + return std::nullopt; + } + return serializer_.deserialize( + *existing->callback->result, + serdes_context{ + .operation_id = operation_id_, + .durable_execution_arn = state_->durable_execution_arn(), + .recursive_level = state_->recursive_level(), + }); + } + + throw execution_suspended( + "Callback result has not been received: " + operation_id_); + } + + private: + std::string callback_id_; + std::string operation_id_; + execution_state* state_; + Serializer serializer_; +}; + +template < + typename Result = std::string, + typename Serializer = detail::callback_default_serdes_t> + requires serializer_for +[[nodiscard]] callback_handle create_callback( + callback_config config = {}, Serializer serializer = {}) { + auto& context = current_context(); + const auto identifier = context.reserve_operation( + operation_subtype::callback, operation_type::callback, + config.name ? std::optional{*config.name} + : std::nullopt); + const auto& id = identifier.require_operation_id(); + auto& state = context.state(); + auto existing = state.find_operation(id); + + if (existing) { + detail::validate_replay_identity(*existing, identifier); + context.before_operation(id, false); + } else { + context.before_operation(id, false); + existing = state.checkpoint(operation_update::callback_start( + identifier, + callback_options{ + .timeout_seconds = + detail::callback_seconds(config.timeout, "timeout"), + .heartbeat_timeout_seconds = detail::callback_seconds( + config.heartbeat_timeout, "heartbeat_timeout"), + })); + } + + if (!existing || !existing->callback) { + throw callback_error( + "Callback checkpoint is missing callback details", id); + } + return callback_handle{ + existing->callback->callback_id, id, state, std::move(serializer)}; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/child_context.hpp b/include/aws/durable_execution/child_context.hpp new file mode 100644 index 0000000..eea03c3 --- /dev/null +++ b/include/aws/durable_execution/child_context.hpp @@ -0,0 +1,349 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/execution_state.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +inline constexpr std::size_t child_context_checkpoint_size_limit = + 256U * 1024U; + +struct child_context_config { + std::optional name; + bool is_virtual{false}; + std::string sub_type{ + std::string{operation_subtype::run_in_child_context}}; +}; + +namespace detail { + +template +decltype(auto) invoke_child(Function& function, durable_context& context) { + if constexpr (std::invocable) { + return std::invoke(function, context); + } else if constexpr (std::invocable) { + return std::invoke(function); + } else { + static_assert( + std::invocable, + "A child-context callable must accept durable_context& or no arguments"); + } +} + +template +using child_raw_result_t = decltype(invoke_child( + std::declval(), std::declval())); + +template +using child_result_t = std::conditional_t< + std::is_void_v>, std::monostate, + std::remove_cvref_t>>; + +[[nodiscard]] inline callable_error child_failure( + const operation& existing) { + const error_object* error = + existing.context && existing.context->error + ? &*existing.context->error + : nullptr; + return callable_error( + error && error->message + ? *error->message + : "Child context failed without an ErrorObject", + error && error->type ? *error->type : std::string{}); +} + +template +[[nodiscard]] auto run_child_context_impl( + Function&& function, Serializer serializer, Summary summary, + child_context_config config) + -> std::remove_cvref_t> { + using raw_result = child_raw_result_t; + using result_type = child_result_t; + static_assert( + serializer_for, + "The supplied child-context serializer does not satisfy serializer_for"); + if constexpr (!std::same_as) { + static_assert( + std::invocable && + std::convertible_to< + std::invoke_result_t, + std::string>, + "A child-context summary generator must return std::string"); + } + + auto& parent = current_context(); + const bool parent_replaying = parent.is_replaying(); + const auto identifier = parent.reserve_operation( + config.sub_type, operation_type::context, + config.name ? std::optional{*config.name} + : std::nullopt); + const auto& id = identifier.require_operation_id(); + auto& state = parent.state(); + const auto existing = state.find_operation(id); + const bool child_replaying = + parent_replaying && (config.is_virtual || existing != nullptr); + + if (config.is_virtual && existing) { + throw invalid_state_error( + "Child context virtual mode changed for operation " + id); + } + if (!config.is_virtual && !existing) { + operation_id_generator child_ids{id}; + if (state.find_operation(child_ids.next())) { + throw invalid_state_error( + "Child context virtual mode changed for operation " + id); + } + } + + bool replay_children = false; + if (existing) { + validate_replay_identity(*existing, identifier); + if (!existing->status.is_known()) { + throw invalid_state_error( + "Child context has an unknown future status: " + + std::string{existing->status.wire_value()}); + } + replay_children = + existing->context && existing->context->replay_children; + + if (existing->status == operation_status::succeeded && + !replay_children) { + parent.before_operation(id, false); + if (!existing->context || !existing->context->result) { + if constexpr (std::is_void_v) { + return; + } else { + throw invalid_state_error( + "Succeeded child context has no result payload: " + id); + } + } + auto result = serializer.deserialize( + *existing->context->result, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + if constexpr (std::is_void_v) { + (void)result; + return; + } else { + return result; + } + } + + if (existing->status == operation_status::failed || + existing->status == operation_status::cancelled || + existing->status == operation_status::timed_out || + existing->status == operation_status::stopped) { + throw child_failure(*existing); + } + if (existing->status != operation_status::started && + !(existing->status == operation_status::succeeded && + replay_children)) { + throw invalid_state_error( + "Child context is in an unsupported state: " + + std::string{existing->status.wire_value()}); + } + } + + const operation_identifier child_identifier{ + .operation_id = std::nullopt, + .sub_type = std::string{operation_subtype::execution}, + .parent_id = + config.is_virtual ? parent.identifier().parent_id + : std::optional{id}, + .name = std::nullopt, + .type = std::nullopt, + }; + durable_context child{ + state, child_identifier, std::optional{id}, + child_replaying}; + + if (!config.is_virtual) { + parent.before_operation(id, true); + if (!existing) { + state.checkpoint(operation_update::context_start(identifier)); + } + } + + const bool attempt_is_replay = static_cast(existing); + const bool is_replaying_children = + child_replaying || replay_children; + const auto attempt_started = std::chrono::system_clock::now(); + bool attempt_notified = false; + state.notify_attempt_start( + id, 1U, attempt_started, attempt_is_replay, + is_replaying_children); + try { + result_type result = [&]() -> result_type { + scoped_context binding{child}; + if constexpr (std::is_void_v) { + invoke_child(function, child); + return {}; + } else { + return invoke_child(function, child); + } + }(); + state.notify_attempt_end( + id, 1U, attempt_started, std::chrono::system_clock::now(), + true, nullptr, attempt_is_replay, is_replaying_children); + attempt_notified = true; + + if (config.is_virtual || replay_children) { + if constexpr (std::is_void_v) { + return; + } else { + return result; + } + } + + std::string payload = serializer.serialize( + result, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + bool should_replay_children = false; + if (payload.size() > child_context_checkpoint_size_limit) { + should_replay_children = true; + if constexpr (std::same_as) { + payload.clear(); + } else { + payload = std::invoke(summary, std::as_const(result)); + } + } + + state.checkpoint(operation_update::context_succeed( + identifier, std::move(payload), should_replay_children)); + if constexpr (std::is_void_v) { + return; + } else { + if (should_replay_children) { + return result; + } + const auto completed = state.find_operation(id); + if (!completed || !completed->context || + !completed->context->result) { + throw invalid_state_error( + "Completed child context is missing its result payload"); + } + return serializer.deserialize( + *completed->context->result, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + } + } catch (const execution_suspended&) { + throw; + } catch (const checkpoint_error&) { + throw; + } catch (const state_fetch_error&) { + throw; + } catch (const durable_error& error) { + const auto serialized_error = exception_to_error(error); + if (!attempt_notified) { + state.notify_attempt_end( + id, 1U, attempt_started, std::chrono::system_clock::now(), + false, &serialized_error, attempt_is_replay, + is_replaying_children); + } + if (!config.is_virtual) { + state.checkpoint(operation_update::context_fail( + identifier, serialized_error)); + } + throw; + } catch (const std::exception& error) { + const auto serialized_error = exception_to_error(error); + state.notify_attempt_end( + id, 1U, attempt_started, std::chrono::system_clock::now(), + false, &serialized_error, attempt_is_replay, + is_replaying_children); + if (!config.is_virtual) { + state.checkpoint( + operation_update::context_fail(identifier, serialized_error)); + } + throw callable_error(error.what(), serialized_error.type.value_or("")); + } catch (...) { + error_object serialized_error{ + .message = "Unknown non-standard exception", + .type = "unknown", + .data = std::nullopt, + .stack_trace = {}, + }; + state.notify_attempt_end( + id, 1U, attempt_started, std::chrono::system_clock::now(), + false, &serialized_error, attempt_is_replay, + is_replaying_children); + if (!config.is_virtual) { + state.checkpoint( + operation_update::context_fail(identifier, serialized_error)); + } + throw callable_error("Unknown non-standard exception", "unknown"); + } +} + +} // namespace detail + +template + requires( + std::invocable || + std::invocable) +[[nodiscard]] auto run_in_child_context( + Function&& function, child_context_config config = {}) + -> std::remove_cvref_t> { + using result_type = detail::child_result_t; + return detail::run_child_context_impl( + std::forward(function), default_serdes{}, + nullptr, std::move(config)); +} + +template + requires( + (std::invocable || + std::invocable) && + serializer_for>) +[[nodiscard]] auto run_in_child_context( + Function&& function, Serializer serializer, + child_context_config config = {}) + -> std::remove_cvref_t> { + return detail::run_child_context_impl( + std::forward(function), std::move(serializer), nullptr, + std::move(config)); +} + +template + requires( + (std::invocable || + std::invocable) && + serializer_for>) +[[nodiscard]] auto run_in_child_context( + Function&& function, Serializer serializer, Summary summary, + child_context_config config = {}) + -> std::remove_cvref_t> { + return detail::run_child_context_impl( + std::forward(function), std::move(serializer), + std::move(summary), std::move(config)); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/context.hpp b/include/aws/durable_execution/context.hpp new file mode 100644 index 0000000..6f74657 --- /dev/null +++ b/include/aws/durable_execution/context.hpp @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "aws/durable_execution/model.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +class execution_state; +class durable_context; + +namespace detail { +class scoped_operation_reservation; +[[nodiscard]] std::optional +consume_operation_reservation( + durable_context& context, operation_type type); +} // namespace detail + +class operation_id_generator { + public: + explicit operation_id_generator( + std::optional prefix = std::nullopt, + std::uint64_t initial_counter = 0); + + [[nodiscard]] std::string next(); + [[nodiscard]] std::string reserve(std::string_view local_id); + [[nodiscard]] std::uint64_t current() const noexcept { return counter_; } + [[nodiscard]] const std::optional& prefix() const noexcept { + return prefix_; + } + + private: + [[nodiscard]] std::string make_id(std::string_view value) const; + + std::optional prefix_; + std::uint64_t counter_{0}; + std::unordered_set local_ids_; +}; + +class durable_context { + public: + durable_context( + execution_state& state, operation_identifier identifier, + std::optional step_id_prefix = std::nullopt, + bool replaying = false, std::uint64_t initial_step = 0); + + [[nodiscard]] execution_state& state() const noexcept { return *state_; } + [[nodiscard]] const operation_identifier& identifier() const noexcept { + return identifier_; + } + [[nodiscard]] bool is_replaying() const noexcept { return replaying_; } + [[nodiscard]] std::uint64_t current_operation_index() const noexcept { + return id_generator_.current(); + } + [[nodiscard]] durable_context fork_at( + std::uint64_t operation_index) const; + [[nodiscard]] std::string reserve_operation_id( + std::optional local_id = std::nullopt); + + [[nodiscard]] operation_identifier reserve_operation( + std::string_view sub_type, operation_type type, + std::optional name = std::nullopt); + + [[nodiscard]] operation_identifier reserve_operation( + std::string_view local_id, std::string_view sub_type, operation_type type, + std::optional name); + + void before_operation(std::string_view operation_id, bool executes_user_code); + + private: + execution_state* state_; + operation_identifier identifier_; + operation_id_generator id_generator_; + bool replaying_; +}; + +namespace detail { + +class scoped_operation_reservation { + public: + scoped_operation_reservation( + durable_context& context, operation_identifier identifier, + operation_type expected_type); + ~scoped_operation_reservation(); + + scoped_operation_reservation( + const scoped_operation_reservation&) = delete; + scoped_operation_reservation& operator=( + const scoped_operation_reservation&) = delete; + + private: + friend std::optional + consume_operation_reservation( + durable_context& context, operation_type type); + + durable_context* context_; + operation_identifier identifier_; + operation_type expected_type_; + scoped_operation_reservation* previous_; + bool consumed_{false}; +}; + +} // namespace detail + +class scoped_context { + public: + explicit scoped_context(durable_context& context) noexcept; + ~scoped_context(); + + scoped_context(const scoped_context&) = delete; + scoped_context& operator=(const scoped_context&) = delete; + + private: + durable_context* previous_; +}; + +class scoped_non_durable_region { + public: + scoped_non_durable_region() noexcept; + ~scoped_non_durable_region(); + + scoped_non_durable_region(const scoped_non_durable_region&) = delete; + scoped_non_durable_region& operator=(const scoped_non_durable_region&) = delete; + + private: + bool previous_; +}; + +[[nodiscard]] durable_context& current_context(); +[[nodiscard]] durable_context* try_current_context() noexcept; + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/detail/blake2b.hpp b/include/aws/durable_execution/detail/blake2b.hpp new file mode 100644 index 0000000..9eb2ec6 --- /dev/null +++ b/include/aws/durable_execution/detail/blake2b.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +namespace aws::durable_execution { +inline namespace v1 { +namespace detail { + +// Stable SDK operation-ID contract: BLAKE2b-512 rendered as lowercase +// hexadecimal and truncated to the first 64 characters. +[[nodiscard]] std::string blake2b_512_hex_64(std::string_view input); + +} // namespace detail +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/durable_execution.hpp b/include/aws/durable_execution/durable_execution.hpp new file mode 100644 index 0000000..f2331d2 --- /dev/null +++ b/include/aws/durable_execution/durable_execution.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "aws/durable_execution/callback.hpp" +#include "aws/durable_execution/child_context.hpp" +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/execution_state.hpp" +#include "aws/durable_execution/extension.hpp" +#include "aws/durable_execution/flow.hpp" +#include "aws/durable_execution/handler.hpp" +#include "aws/durable_execution/invoke.hpp" +#include "aws/durable_execution/local_runner.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/parallel.hpp" +#include "aws/durable_execution/plugin.hpp" +#include "aws/durable_execution/runtime.hpp" +#include "aws/durable_execution/replay_safe.hpp" +#include "aws/durable_execution/recurse.hpp" +#include "aws/durable_execution/serdes.hpp" +#include "aws/durable_execution/service_client.hpp" +#include "aws/durable_execution/version.hpp" +#include "aws/durable_execution/wait_for_callback.hpp" +#include "aws/durable_execution/wait_for_condition.hpp" +#include "aws/durable_execution/with_retry.hpp" +#include "aws/durable_execution/wire.hpp" diff --git a/include/aws/durable_execution/error.hpp b/include/aws/durable_execution/error.hpp new file mode 100644 index 0000000..2156d3a --- /dev/null +++ b/include/aws/durable_execution/error.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace aws::durable_execution { +inline namespace v1 { + +enum class error_code { + invalid_argument, + invalid_state, + checkpoint_failed, + state_fetch_failed, + serialization_failed, + callable_failed, + callback_failed, + step_interrupted, + protocol_error, +}; + +class durable_error : public std::runtime_error { + public: + durable_error(error_code code, std::string message) + : std::runtime_error(std::move(message)), code_(code) {} + + [[nodiscard]] error_code code() const noexcept { return code_; } + + private: + error_code code_; +}; + +class invalid_state_error final : public durable_error { + public: + explicit invalid_state_error(std::string message) + : durable_error(error_code::invalid_state, std::move(message)) {} +}; + +class checkpoint_error final : public durable_error { + public: + checkpoint_error(std::string message, bool retryable) + : durable_error(error_code::checkpoint_failed, std::move(message)), + retryable_(retryable) {} + + [[nodiscard]] bool retryable() const noexcept { return retryable_; } + + private: + bool retryable_; +}; + +class state_fetch_error final : public durable_error { + public: + state_fetch_error(std::string message, bool retryable) + : durable_error(error_code::state_fetch_failed, std::move(message)), + retryable_(retryable) {} + + [[nodiscard]] bool retryable() const noexcept { return retryable_; } + + private: + bool retryable_; +}; + +class serialization_error final : public durable_error { + public: + explicit serialization_error(std::string message) + : durable_error(error_code::serialization_failed, std::move(message)) {} +}; + +class callable_error final : public durable_error { + public: + callable_error(std::string message, std::string type = {}) + : durable_error(error_code::callable_failed, std::move(message)), + type_(std::move(type)) {} + + [[nodiscard]] const std::string& type() const noexcept { return type_; } + + private: + std::string type_; +}; + +class callback_error final : public durable_error { + public: + callback_error(std::string message, std::string callback_id = {}) + : durable_error(error_code::callback_failed, std::move(message)), + callback_id_(std::move(callback_id)) {} + + [[nodiscard]] const std::string& callback_id() const noexcept { + return callback_id_; + } + + private: + std::string callback_id_; +}; + +class step_interrupted_error final : public durable_error { + public: + explicit step_interrupted_error( + std::string message, std::string operation_id = {}) + : durable_error(error_code::step_interrupted, std::move(message)), + operation_id_(std::move(operation_id)) {} + + [[nodiscard]] const std::string& operation_id() const noexcept { + return operation_id_; + } + + private: + std::string operation_id_; +}; + +class execution_suspended final : public std::exception { + public: + explicit execution_suspended( + std::string reason, + std::optional resume_after = std::nullopt) + : reason_(std::move(reason)), resume_after_(resume_after) {} + + [[nodiscard]] const char* what() const noexcept override { + return reason_.c_str(); + } + + [[nodiscard]] std::optional resume_after() const noexcept { + return resume_after_; + } + + private: + std::string reason_; + std::optional resume_after_; +}; + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/execution_state.hpp b/include/aws/durable_execution/execution_state.hpp new file mode 100644 index 0000000..e232185 --- /dev/null +++ b/include/aws/durable_execution/execution_state.hpp @@ -0,0 +1,172 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/plugin.hpp" +#include "aws/durable_execution/service_client.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct checkpoint_batcher_config { + std::size_t max_batch_size_bytes{750U * 1024U}; + std::size_t max_batch_operations{250}; + std::uint32_t coalescing_yields{1}; + std::chrono::microseconds coalescing_delay{100}; +}; + +struct transparent_string_hash { + using is_transparent = void; + + [[nodiscard]] std::size_t operator()(std::string_view value) const noexcept { + return std::hash{}(value); + } + + [[nodiscard]] std::size_t operator()(const std::string& value) const noexcept { + return (*this)(std::string_view{value}); + } +}; + +class execution_state { + public: + using operation_snapshot = std::shared_ptr; + + class checkpoint_batching_scope { + public: + checkpoint_batching_scope() noexcept = default; + explicit checkpoint_batching_scope(execution_state& state) noexcept; + ~checkpoint_batching_scope(); + + checkpoint_batching_scope(const checkpoint_batching_scope&) = delete; + checkpoint_batching_scope& operator=( + const checkpoint_batching_scope&) = delete; + checkpoint_batching_scope(checkpoint_batching_scope&& other) noexcept; + checkpoint_batching_scope& operator=( + checkpoint_batching_scope&& other) noexcept; + + private: + execution_state* state_{nullptr}; + }; + + execution_state( + std::string durable_execution_arn, std::string checkpoint_token, + service_client& client, + checkpoint_batcher_config batcher_config = {}, + lambda_invocation_metadata invocation_metadata = {}); + execution_state( + std::string durable_execution_arn, std::string checkpoint_token, + service_client& client, checkpoint_batcher_config batcher_config, + lambda_invocation_metadata invocation_metadata, + detail::plugin_manager* plugins); + + void initialize(const initial_execution_state& initial_state); + + [[nodiscard]] const std::string& durable_execution_arn() const noexcept { + return durable_execution_arn_; + } + [[nodiscard]] std::string checkpoint_token() const; + [[nodiscard]] operation_snapshot find_operation( + std::string_view id) const; + [[nodiscard]] operation_snapshot execution_operation() const; + [[nodiscard]] bool has_prior_operations() const; + [[nodiscard]] std::string_view input_payload() const noexcept; + [[nodiscard]] std::uint32_t recursive_level() const noexcept { + return recursive_level_; + } + [[nodiscard]] const lambda_invocation_metadata& invocation_metadata() + const noexcept { + return invocation_metadata_; + } + [[nodiscard]] std::size_t operation_count() const; + [[nodiscard]] std::vector operation_snapshots() const; + [[nodiscard]] std::vector operation_snapshots( + std::span operation_ids) const; + + void notify_replay_operation( + std::string_view operation_id, + bool is_replaying_children = false) noexcept; + void notify_attempt_start( + std::string_view operation_id, std::uint32_t attempt, + timestamp started, bool is_replay, + bool is_replaying_children = false) noexcept; + void notify_attempt_end( + std::string_view operation_id, std::uint32_t attempt, + timestamp started, timestamp ended, bool succeeded, + const error_object* error, bool is_replay, + bool is_replaying_children = false) noexcept; + void notify_external_updates( + std::span operation_ids) noexcept; + + operation_snapshot checkpoint(const operation_update& update); + void checkpoint(std::span updates); + [[nodiscard]] checkpoint_batching_scope enable_checkpoint_batching() noexcept; + + private: + struct pending_checkpoint { + std::vector updates; + std::condition_variable completed_condition; + bool completed{false}; + std::exception_ptr error; + }; + + void load_operations(std::span operations); + void fetch_remaining_locked(std::optional marker); + void checkpoint_direct(std::span updates); + void checkpoint_batched(std::span updates); + void process_pending_batches(); + void release_checkpoint_batching() noexcept; + void notify_operation_start( + const operation_snapshot& value, bool is_replay, + bool is_replaying_children = false) noexcept; + void notify_operation_end( + const operation_snapshot& value, bool is_replay) noexcept; + [[nodiscard]] std::size_t estimate_update_size( + const operation_update& update) const noexcept; + + std::string durable_execution_arn_; + std::string checkpoint_token_; + std::string input_payload_; + std::uint32_t recursive_level_{0}; + lambda_invocation_metadata invocation_metadata_; + service_client* client_; + checkpoint_batcher_config batcher_config_; + detail::plugin_manager* plugins_; + + mutable std::shared_mutex operations_mutex_; + std::unordered_map< + std::string, operation_snapshot, transparent_string_hash, + std::equal_to<>> + operations_; + + mutable std::mutex plugin_notification_mutex_; + std::unordered_set operation_start_notifications_; + std::unordered_set operation_end_notifications_; + + mutable std::mutex checkpoint_call_mutex_; + std::atomic checkpoint_batching_scopes_{0}; + std::mutex batch_queue_mutex_; + std::deque> pending_checkpoints_; + bool batch_processor_active_{false}; + std::exception_ptr batch_failure_; +}; + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/extension.hpp b/include/aws/durable_execution/extension.hpp new file mode 100644 index 0000000..2edec3a --- /dev/null +++ b/include/aws/durable_execution/extension.hpp @@ -0,0 +1,406 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/callback.hpp" +#include "aws/durable_execution/child_context.hpp" +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/invoke.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/serdes.hpp" +#include "aws/durable_execution/wait_for_condition.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +template +struct extension_step_result { + State value; + std::optional retry_after; + + [[nodiscard]] static extension_step_result succeed(State value) { + return extension_step_result{ + .value = std::move(value), + .retry_after = std::nullopt, + }; + } + + [[nodiscard]] static extension_step_result retry( + State state, std::chrono::seconds delay) { + if (delay < std::chrono::seconds{1}) { + throw durable_error( + error_code::invalid_argument, + "extension retry delay must be at least one second"); + } + return extension_step_result{ + .value = std::move(state), + .retry_after = delay, + }; + } + + [[nodiscard]] bool is_retry() const noexcept { + return retry_after.has_value(); + } +}; + +namespace detail { + +[[nodiscard]] inline bool reserved_operation_subtype( + std::string_view value) noexcept { + return value == operation_subtype::step || + value == operation_subtype::wait || + value == operation_subtype::callback || + value == operation_subtype::run_in_child_context || + value == operation_subtype::map || + value == operation_subtype::map_iteration || + value == operation_subtype::parallel || + value == operation_subtype::parallel_branch || + value == operation_subtype::wait_for_callback || + value == operation_subtype::wait_for_condition || + value == operation_subtype::chained_invoke || + value == operation_subtype::execution; +} + +inline void validate_extension_subtype(std::string_view value) { + if (value.empty() || + value.find_first_not_of(" \t\r\n") == std::string_view::npos) { + throw durable_error( + error_code::invalid_argument, + "extension operation subtype must not be blank"); + } + if (reserved_operation_subtype(value)) { + throw durable_error( + error_code::invalid_argument, + "extension operation subtype is reserved by the SDK: " + + std::string{value}); + } +} + +template +extension_step_result invoke_extension_step( + Function& function, const std::optional& current, + std::uint32_t attempt) { + if constexpr ( + std::invocable< + Function&, const std::optional&, std::uint32_t>) { + return std::invoke(function, current, attempt); + } else if constexpr ( + std::invocable&>) { + return std::invoke(function, current); + } else if constexpr ( + std::invocable) { + if (!current) { + throw invalid_state_error( + "extension stateful step requires an initial state"); + } + return std::invoke(function, *current, attempt); + } else if constexpr (std::invocable) { + if (!current) { + throw invalid_state_error( + "extension stateful step requires an initial state"); + } + return std::invoke(function, *current); + } else if constexpr (std::invocable) { + return std::invoke(function, attempt); + } else if constexpr (std::invocable) { + return std::invoke(function); + } else { + static_assert( + std::invocable< + Function&, const std::optional&, std::uint32_t>, + "An extension stateful step must return extension_step_result"); + } +} + +template +std::optional> invoke_extension_retry( + RetryStrategy& strategy, const std::exception& error, + const std::optional& current, std::uint32_t attempt) { + if constexpr ( + std::same_as, std::nullptr_t>) { + return std::nullopt; + } else if constexpr ( + std::invocable< + RetryStrategy&, const std::exception&, + const std::optional&, std::uint32_t>) { + return std::invoke(strategy, error, current, attempt); + } else if constexpr ( + std::invocable< + RetryStrategy&, const std::exception&, const State&, + std::uint32_t>) { + if (!current) return std::nullopt; + return std::invoke(strategy, error, *current, attempt); + } else { + static_assert( + std::invocable< + RetryStrategy&, const std::exception&, + const std::optional&, std::uint32_t>, + "An extension retry strategy must return an optional step result"); + } +} + +} // namespace detail + +class extension_context; + +class extension_operation { + public: + extension_operation(const extension_operation&) = delete; + extension_operation& operator=(const extension_operation&) = delete; + + extension_operation(extension_operation&& other) noexcept + : context_(std::exchange(other.context_, nullptr)), + operation_id_(std::move(other.operation_id_)), + name_(std::move(other.name_)), + claimed_(std::exchange(other.claimed_, true)) {} + + extension_operation& operator=(extension_operation&&) = delete; + + template + requires( + (std::invocable || + std::invocable)) + [[nodiscard]] auto step( + Function&& function, std::string_view sub_type, + Serializer serializer, step_config config = {}) + -> std::remove_cvref_t> { + auto identifier = claim(operation_type::step, sub_type); + detail::scoped_operation_reservation reservation{ + *context_, std::move(identifier), operation_type::step}; + return ::aws::durable_execution::step( + std::forward(function), std::move(serializer), + std::move(config)); + } + + template + requires( + std::invocable || + std::invocable) + [[nodiscard]] auto step( + Function&& function, std::string_view sub_type, + step_config config = {}) + -> std::remove_cvref_t> { + using raw_result = detail::step_callable_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, + std::remove_cvref_t>; + return step( + std::forward(function), sub_type, + default_serdes{}, std::move(config)); + } + + template < + typename State, typename Function, + typename RetryStrategy = std::nullptr_t, + typename Serializer = default_serdes> + requires serializer_for + [[nodiscard]] State stateful_step( + Function&& function, std::string_view sub_type, + std::optional initial_state = std::nullopt, + RetryStrategy retry_strategy = nullptr, + Serializer serializer = {}) { + auto identifier = claim(operation_type::step, sub_type); + detail::scoped_operation_reservation reservation{ + *context_, std::move(identifier), operation_type::step}; + + std::optional next_delay; + auto check = + [function = std::forward(function), + retry_strategy = std::move(retry_strategy), + &next_delay]( + const std::optional& current, + std::uint32_t attempt) mutable -> State { + try { + auto result = detail::invoke_extension_step( + function, current, attempt); + next_delay = result.retry_after; + return std::move(result.value); + } catch (const std::exception& error) { + auto retry = detail::invoke_extension_retry< + RetryStrategy, State>( + retry_strategy, error, current, attempt); + if (!retry) throw; + next_delay = retry->retry_after; + return std::move(retry->value); + } + }; + auto strategy = + [&next_delay](const State&, std::uint32_t) { + return std::exchange(next_delay, std::nullopt); + }; + return wait_for_condition( + std::move(check), std::move(initial_state), + std::move(strategy), {}, std::move(serializer)); + } + + void wait( + std::chrono::seconds duration, std::string_view sub_type) { + auto identifier = claim(operation_type::wait, sub_type); + detail::scoped_operation_reservation reservation{ + *context_, std::move(identifier), operation_type::wait}; + ::aws::durable_execution::wait(duration); + } + + template < + typename Result = std::string, typename Payload, + typename PayloadSerializer = + default_serdes>, + typename ResultSerializer = default_serdes> + requires serializer_for< + PayloadSerializer, std::remove_cvref_t> && + serializer_for + [[nodiscard]] std::optional invoke( + std::string_view function_name, const Payload& payload, + std::string_view sub_type, invoke_config config = {}, + PayloadSerializer payload_serializer = {}, + ResultSerializer result_serializer = {}) { + auto identifier = + claim(operation_type::chained_invoke, sub_type); + detail::scoped_operation_reservation reservation{ + *context_, std::move(identifier), + operation_type::chained_invoke}; + return ::aws::durable_execution::invoke( + function_name, payload, std::move(config), + std::move(payload_serializer), std::move(result_serializer)); + } + + template < + typename Result = std::string, + typename Serializer = detail::callback_default_serdes_t> + requires serializer_for + [[nodiscard]] callback_handle create_callback( + std::string_view sub_type, callback_config config = {}, + Serializer serializer = {}) { + auto identifier = claim(operation_type::callback, sub_type); + detail::scoped_operation_reservation reservation{ + *context_, std::move(identifier), operation_type::callback}; + return ::aws::durable_execution::create_callback( + std::move(config), std::move(serializer)); + } + + template + requires( + std::invocable || + std::invocable) + [[nodiscard]] auto run_in_child_context( + Function&& function, std::string_view sub_type, + child_context_config config = {}) + -> std::remove_cvref_t> { + auto identifier = claim(operation_type::context, sub_type); + detail::scoped_operation_reservation reservation{ + *context_, std::move(identifier), operation_type::context}; + return ::aws::durable_execution::run_in_child_context( + std::forward(function), std::move(config)); + } + + template + requires( + (std::invocable || + std::invocable) && + serializer_for>) + [[nodiscard]] auto run_in_child_context( + Function&& function, std::string_view sub_type, + Serializer serializer, child_context_config config = {}) + -> std::remove_cvref_t> { + auto identifier = claim(operation_type::context, sub_type); + detail::scoped_operation_reservation reservation{ + *context_, std::move(identifier), operation_type::context}; + return ::aws::durable_execution::run_in_child_context( + std::forward(function), std::move(serializer), + std::move(config)); + } + + private: + friend class extension_context; + + extension_operation( + durable_context& context, std::string operation_id, + std::optional name) + : context_(&context), + operation_id_(std::move(operation_id)), + name_(std::move(name)) {} + + [[nodiscard]] operation_identifier claim( + operation_type type, std::string_view sub_type) { + if (!context_ || ¤t_context() != context_) { + throw invalid_state_error( + "An extension reservation can only be used in its original context"); + } + if (claimed_) { + throw invalid_state_error( + "An extension operation reservation can only be used once"); + } + detail::validate_extension_subtype(sub_type); + claimed_ = true; + return operation_identifier{ + .operation_id = operation_id_, + .sub_type = std::string{sub_type}, + .parent_id = context_->identifier().parent_id, + .name = name_, + .type = type, + }; + } + + durable_context* context_; + std::string operation_id_; + std::optional name_; + bool claimed_{false}; +}; + +class extension_context { + public: + explicit extension_context(durable_context& context) noexcept + : context_(&context) {} + + [[nodiscard]] bool is_replaying() const noexcept { + return context_->is_replaying(); + } + + [[nodiscard]] std::uint32_t recursive_level() const noexcept { + return context_->state().recursive_level(); + } + + [[nodiscard]] const lambda_invocation_metadata& + invocation_metadata() const noexcept { + return context_->state().invocation_metadata(); + } + + [[nodiscard]] extension_operation reserve( + std::optional name = std::nullopt, + std::optional local_operation_id = std::nullopt) { + if (¤t_context() != context_) { + throw invalid_state_error( + "An extension context can only reserve in its active context"); + } + if (name && + (name->empty() || + name->find_first_not_of(" \t\r\n") == std::string::npos)) { + throw durable_error( + error_code::invalid_argument, + "extension operation name must not be blank"); + } + return extension_operation{ + *context_, + context_->reserve_operation_id(local_operation_id), + std::move(name)}; + } + + private: + durable_context* context_; +}; + +[[nodiscard]] inline extension_context get_extension_context() { + return extension_context{current_context()}; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/flow.hpp b/include/aws/durable_execution/flow.hpp new file mode 100644 index 0000000..1dabd64 --- /dev/null +++ b/include/aws/durable_execution/flow.hpp @@ -0,0 +1,1466 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/child_context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/parallel.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +class flow_definition_error final : public durable_error { + public: + explicit flow_definition_error(std::string message) + : durable_error(error_code::invalid_argument, std::move(message)) {} +}; + +class flow_node_access_error final : public durable_error { + public: + explicit flow_node_access_error(std::string message) + : durable_error(error_code::callable_failed, std::move(message)) {} +}; + +enum class flow_node_status { succeeded, failed, skipped }; +enum class flow_dependency_condition { succeeded, failed, completed }; +enum class flow_dependency_mode { all, any }; +enum class flow_output_kind { outcome, error, result }; + +[[nodiscard]] constexpr std::string_view to_string( + flow_node_status value) noexcept { + switch (value) { + case flow_node_status::succeeded: return "SUCCEEDED"; + case flow_node_status::failed: return "FAILED"; + case flow_node_status::skipped: return "SKIPPED"; + } + return {}; +} + +[[nodiscard]] constexpr std::string_view to_string( + flow_output_kind value) noexcept { + switch (value) { + case flow_output_kind::outcome: return "OUTCOME"; + case flow_output_kind::error: return "ERROR"; + case flow_output_kind::result: return "RESULT"; + } + return {}; +} + +template +struct flow_node_result { + flow_node_status status{flow_node_status::skipped}; + std::optional outcome; + std::optional error; +}; + +struct flow_node_record { + flow_node_status status{flow_node_status::skipped}; + std::optional serialized_outcome; + std::optional error; + std::vector handled_failures; +}; + +class flow_node_context; +struct flow_builder_state; + +namespace detail { + +struct flow_dependency_tree { + enum class kind { leaf, all, any }; + + kind type{kind::leaf}; + std::size_t node_index{0}; + flow_dependency_condition condition{ + flow_dependency_condition::succeeded}; + std::vector> children; +}; + +struct flow_output_reference { + std::size_t node_index{0}; + flow_output_kind kind{flow_output_kind::outcome}; +}; + +struct flow_result_serdes; + +template < + typename Function, + bool WithContext = + std::invocable> +struct flow_callable_result; + +template +struct flow_callable_result { + using type = std::invoke_result_t; +}; + +template +struct flow_callable_result { + using type = std::invoke_result_t; +}; + +template +using flow_callable_result_t = + typename flow_callable_result::type; + +struct flow_node_definition { + std::size_t index{0}; + std::string name; + std::shared_ptr dependency; + std::type_index outcome_type{typeid(void)}; + mutable std::function + execute; + std::function + deserialize_outcome; +}; + +} // namespace detail + +class dependency_expression { + public: + dependency_expression() = default; + + [[nodiscard]] dependency_expression operator&&( + const dependency_expression& other) const { + return combine(other, flow_dependency_mode::all); + } + + [[nodiscard]] dependency_expression operator||( + const dependency_expression& other) const { + return combine(other, flow_dependency_mode::any); + } + + [[nodiscard]] bool valid() const noexcept { + return tree_ != nullptr && !builder_.expired(); + } + + private: + dependency_expression( + std::weak_ptr builder, + std::shared_ptr tree) + : builder_(std::move(builder)), tree_(std::move(tree)) {} + + [[nodiscard]] dependency_expression combine( + const dependency_expression& other, + flow_dependency_mode mode) const; + + std::weak_ptr builder_; + std::shared_ptr tree_; + + template + friend class flow_node_handle; + friend class flow_builder; + friend struct flow_builder_state; + friend struct detail::flow_node_definition; + friend class flow_node_context; + friend class flow_result; + friend struct detail::flow_output_reference; + friend struct detail::flow_dependency_tree; + friend class flow_execution_error; + friend struct detail_flow_access; +}; + +template +class flow_output { + public: + [[nodiscard]] flow_output_kind kind() const noexcept { return kind_; } + + private: + flow_output( + std::weak_ptr builder, std::size_t node_index, + flow_output_kind kind) + : builder_(std::move(builder)), + node_index_(node_index), + kind_(kind) {} + + std::weak_ptr builder_; + std::size_t node_index_; + flow_output_kind kind_; + + friend class flow_builder; + friend class flow_result; + template + friend class flow_node_handle; +}; + +template +class flow_node_handle { + public: + flow_node_handle() = default; + + [[nodiscard]] std::size_t index() const noexcept { return index_; } + [[nodiscard]] std::string_view name() const; + + [[nodiscard]] dependency_expression succeeded() const { + return dependency(flow_dependency_condition::succeeded); + } + [[nodiscard]] dependency_expression failed() const { + return dependency(flow_dependency_condition::failed); + } + [[nodiscard]] dependency_expression completed() const { + return dependency(flow_dependency_condition::completed); + } + + [[nodiscard]] flow_output outcome() const { + return flow_output{builder_, index_, flow_output_kind::outcome}; + } + [[nodiscard]] flow_output error() const { + return flow_output{ + builder_, index_, flow_output_kind::error}; + } + [[nodiscard]] flow_output> result() const { + return flow_output>{ + builder_, index_, flow_output_kind::result}; + } + + private: + flow_node_handle( + std::weak_ptr builder, std::size_t index) + : builder_(std::move(builder)), index_(index) {} + + [[nodiscard]] dependency_expression dependency( + flow_dependency_condition condition) const; + + std::weak_ptr builder_; + std::size_t index_{0}; + + friend class flow_builder; + friend class flow_node_context; + friend class flow_result; +}; + +struct flow_builder_state { + std::vector nodes; + std::vector outputs; + std::vector topological_nodes; + std::vector execution_nodes; + std::vector> execution_ordinals; + bool frozen{false}; +}; + +inline dependency_expression dependency_expression::combine( + const dependency_expression& other, + flow_dependency_mode mode) const { + const auto lhs_builder = builder_.lock(); + const auto rhs_builder = other.builder_.lock(); + if (!lhs_builder || !rhs_builder || lhs_builder != rhs_builder) { + throw flow_definition_error( + "Dependency expressions belong to different flow builders"); + } + if (lhs_builder->frozen) { + throw flow_definition_error( + "Cannot modify a frozen dependency expression"); + } + + auto combined = std::make_shared(); + combined->type = + mode == flow_dependency_mode::all + ? detail::flow_dependency_tree::kind::all + : detail::flow_dependency_tree::kind::any; + const auto append = [&](const dependency_expression& expression) { + if ((mode == flow_dependency_mode::all && + expression.tree_->type == + detail::flow_dependency_tree::kind::all) || + (mode == flow_dependency_mode::any && + expression.tree_->type == + detail::flow_dependency_tree::kind::any)) { + combined->children.insert( + combined->children.end(), expression.tree_->children.begin(), + expression.tree_->children.end()); + } else { + combined->children.push_back(expression.tree_); + } + }; + append(*this); + append(other); + return dependency_expression{lhs_builder, std::move(combined)}; +} + +template +std::string_view flow_node_handle::name() const { + const auto builder = builder_.lock(); + if (!builder || index_ >= builder->nodes.size()) { + throw invalid_state_error("Flow node handle is no longer valid"); + } + return builder->nodes[index_].name; +} + +template +dependency_expression flow_node_handle::dependency( + flow_dependency_condition condition) const { + const auto builder = builder_.lock(); + if (!builder || builder->frozen || index_ >= builder->nodes.size()) { + throw flow_definition_error( + "Flow dependencies can only be created before the graph is frozen"); + } + auto leaf = std::make_shared(); + leaf->type = detail::flow_dependency_tree::kind::leaf; + leaf->node_index = index_; + leaf->condition = condition; + return dependency_expression{builder, std::move(leaf)}; +} + +class flow_node_context { + public: + template + [[nodiscard]] flow_node_result result( + const flow_node_handle& node) const { + validate_dependency(node.builder_, node.index_); + const auto& record = require_record(node.index_); + flow_node_result result{ + .status = record.status, + .error = record.error, + }; + if (record.status == flow_node_status::succeeded && + record.serialized_outcome) { + const auto& definition = definition_->nodes[node.index_]; + const auto value = definition.deserialize_outcome( + *record.serialized_outcome, + serdes_context{ + .operation_id = node_operation_ids_[node.index_], + .durable_execution_arn = durable_execution_arn_, + .recursive_level = recursive_level_, + }); + try { + result.outcome = std::any_cast(value); + } catch (const std::bad_any_cast&) { + throw serialization_error( + "Flow dependency outcome type does not match its node handle"); + } + } + return result; + } + + template + [[nodiscard]] T outcome(const flow_node_handle& node) const { + auto settled = result(node); + if (settled.status != flow_node_status::succeeded || + !settled.outcome) { + throw flow_node_access_error( + "Flow dependency did not produce a successful outcome"); + } + return std::move(*settled.outcome); + } + + template + [[nodiscard]] std::optional error( + const flow_node_handle& node) const { + return result(node).error; + } + + [[nodiscard]] std::optional status( + std::string_view name) const { + for (const auto dependency : direct_dependencies_) { + if (definition_->nodes[dependency].name == name && + (*records_)[dependency]) { + return (*records_)[dependency]->status; + } + } + return std::nullopt; + } + + public: + flow_node_context( + std::shared_ptr definition, + std::size_t current_node, + const std::vector>& records, + std::vector direct_dependencies, + const std::vector& node_operation_ids, + std::string_view durable_execution_arn, std::uint32_t recursive_level) + : definition_(std::move(definition)), + current_node_(current_node), + records_(&records), + direct_dependencies_(std::move(direct_dependencies)), + node_operation_ids_(node_operation_ids), + durable_execution_arn_(durable_execution_arn), + recursive_level_(recursive_level) {} + + private: + void validate_dependency( + const std::weak_ptr& builder, + std::size_t node_index) const { + const auto candidate = builder.lock(); + if (!candidate || candidate.get() != definition_.get()) { + throw flow_node_access_error( + "Flow node handle belongs to another graph"); + } + if (std::ranges::find(direct_dependencies_, node_index) == + direct_dependencies_.end()) { + throw flow_node_access_error( + "Flow node is not a direct dependency of the current node"); + } + } + + [[nodiscard]] const flow_node_record& require_record( + std::size_t node_index) const { + if (node_index >= records_->size() || !(*records_)[node_index]) { + throw flow_node_access_error( + "Flow dependency result is not available"); + } + return *(*records_)[node_index]; + } + + std::shared_ptr definition_; + std::size_t current_node_; + const std::vector>* records_; + std::vector direct_dependencies_; + const std::vector& node_operation_ids_; + std::string durable_execution_arn_; + std::uint32_t recursive_level_; + + friend class flow_builder; + friend struct detail_flow_access; +}; + +class flow_builder { + public: + flow_builder() : state_(std::make_shared()) {} + + template < + typename Function, + typename RawResult = detail::flow_callable_result_t, + typename Result = std::conditional_t< + std::is_void_v, std::monostate, + std::remove_cvref_t>, + typename Serializer = default_serdes> + requires( + (std::invocable || + std::invocable) && + std::copy_constructible> && + serializer_for && + std::copy_constructible) + [[nodiscard]] flow_node_handle node( + std::string name, Function function, + Serializer serializer = {}) { + ensure_mutable(); + if (name.empty() || + std::ranges::all_of(name, [](unsigned char value) { + return value == ' ' || value == '\t' || value == '\r' || + value == '\n'; + })) { + throw flow_definition_error( + "Flow node names must not be blank"); + } + if (std::ranges::any_of( + state_->nodes, + [&](const auto& existing) { return existing.name == name; })) { + throw flow_definition_error( + "Flow node name is duplicated: " + name); + } + + const std::size_t index = state_->nodes.size(); + auto serializer_ptr = + std::make_shared(std::move(serializer)); + detail::flow_node_definition definition; + definition.index = index; + definition.name = std::move(name); + definition.outcome_type = std::type_index{typeid(Result)}; + definition.execute = + [function = std::move(function), serializer_ptr]( + flow_node_context& context, + const serdes_context& serdes_context) mutable { + Result outcome = [&]() -> Result { + if constexpr (std::is_void_v) { + if constexpr ( + std::invocable) { + std::invoke(function, context); + } else { + std::invoke(function); + } + return {}; + } else if constexpr ( + std::invocable) { + return std::invoke(function, context); + } else { + return std::invoke(function); + } + }(); + return flow_node_record{ + .status = flow_node_status::succeeded, + .serialized_outcome = + serializer_ptr->serialize(outcome, serdes_context), + .error = std::nullopt, + .handled_failures = {}, + }; + }; + definition.deserialize_outcome = + [serializer_ptr]( + std::string_view payload, + const serdes_context& serdes_context) -> std::any { + return std::any{ + serializer_ptr->deserialize(payload, serdes_context)}; + }; + state_->nodes.push_back(std::move(definition)); + return flow_node_handle{state_, index}; + } + + template + void depends_on( + const flow_node_handle& target, + dependency_expression expression) { + ensure_mutable(); + const auto target_builder = target.builder_.lock(); + const auto expression_builder = expression.builder_.lock(); + if (!target_builder || target_builder != state_ || + !expression_builder || expression_builder != state_) { + throw flow_definition_error( + "Flow dependency uses nodes from another builder"); + } + if (target.index_ >= state_->nodes.size()) { + throw flow_definition_error("Flow dependency target is invalid"); + } + if (state_->nodes[target.index_].dependency) { + throw flow_definition_error( + "Flow node already has a dependency expression"); + } + state_->nodes[target.index_].dependency = + std::move(expression.tree_); + } + + template + void outputs(const Outputs&... outputs) { + ensure_mutable(); + state_->outputs.clear(); + (append_output(outputs), ...); + } + + [[nodiscard]] std::shared_ptr freeze() { + ensure_mutable(); + validate_dependencies(); + topological_sort(); + compute_execution_nodes(); + state_->frozen = true; + return state_; + } + + private: + void ensure_mutable() const { + if (state_->frozen) { + throw flow_definition_error("Flow builder is already frozen"); + } + } + + template + void append_output(const flow_output& output) { + const auto builder = output.builder_.lock(); + if (!builder || builder != state_ || + output.node_index_ >= state_->nodes.size()) { + throw flow_definition_error( + "Flow output belongs to another builder"); + } + state_->outputs.push_back(detail::flow_output_reference{ + .node_index = output.node_index_, + .kind = output.kind_, + }); + } + + static void collect_leaves( + const std::shared_ptr& tree, + std::vector>& leaves) { + if (tree->type == detail::flow_dependency_tree::kind::leaf) { + leaves.emplace_back(tree->node_index, tree->condition); + return; + } + for (const auto& child : tree->children) { + collect_leaves(child, leaves); + } + } + + void validate_dependencies() { + for (auto& node : state_->nodes) { + if (!node.dependency) { + continue; + } + std::vector> + leaves; + collect_leaves(node.dependency, leaves); + std::unordered_set seen; + for (const auto& [dependency, condition] : leaves) { + (void)condition; + if (dependency >= state_->nodes.size()) { + throw flow_definition_error( + "Flow dependency references an unknown node"); + } + if (dependency == node.index) { + throw flow_definition_error( + "Flow node cannot depend on itself: " + node.name); + } + if (!seen.insert(dependency).second) { + throw flow_definition_error( + "Flow node contains a duplicate dependency: " + + state_->nodes[dependency].name); + } + } + } + } + + void topological_sort() { + const std::size_t count = state_->nodes.size(); + std::vector> adjacency(count); + std::vector indegree(count, 0U); + for (const auto& node : state_->nodes) { + if (!node.dependency) continue; + std::vector> + leaves; + collect_leaves(node.dependency, leaves); + for (const auto& [dependency, condition] : leaves) { + (void)condition; + adjacency[dependency].push_back(node.index); + ++indegree[node.index]; + } + } + for (auto& targets : adjacency) { + std::ranges::sort(targets); + } + + std::priority_queue< + std::size_t, std::vector, std::greater<>> + ready; + for (std::size_t index = 0; index < count; ++index) { + if (indegree[index] == 0U) ready.push(index); + } + state_->topological_nodes.clear(); + while (!ready.empty()) { + const auto current = ready.top(); + ready.pop(); + state_->topological_nodes.push_back(current); + for (const auto target : adjacency[current]) { + if (--indegree[target] == 0U) ready.push(target); + } + } + if (state_->topological_nodes.size() != count) { + std::string cycle_names; + for (std::size_t index = 0; index < count; ++index) { + if (indegree[index] != 0U) { + if (!cycle_names.empty()) cycle_names.append(" -> "); + cycle_names.append(state_->nodes[index].name); + } + } + throw flow_definition_error( + "Flow contains a cycle: " + cycle_names); + } + } + + void compute_execution_nodes() { + std::vector reachable(state_->nodes.size(), false); + std::vector pending; + for (const auto& output : state_->outputs) { + if (!reachable[output.node_index]) { + reachable[output.node_index] = true; + pending.push_back(output.node_index); + } + } + while (!pending.empty()) { + const auto current = pending.back(); + pending.pop_back(); + const auto& dependency = state_->nodes[current].dependency; + if (!dependency) continue; + std::vector> + leaves; + collect_leaves(dependency, leaves); + for (const auto& [dependency_index, condition] : leaves) { + (void)condition; + if (!reachable[dependency_index]) { + reachable[dependency_index] = true; + pending.push_back(dependency_index); + } + } + } + + state_->execution_nodes.clear(); + state_->execution_ordinals.assign( + state_->nodes.size(), std::nullopt); + for (const auto index : state_->topological_nodes) { + if (reachable[index]) { + state_->execution_ordinals[index] = + state_->execution_nodes.size(); + state_->execution_nodes.push_back(index); + } + } + } + + std::shared_ptr state_; + + template + friend class flow_node_handle; + friend class flow_result; +}; + +template +struct flow_result_access; + +class flow_result { + public: + [[nodiscard]] std::size_t output_count() const noexcept { + return definition_->outputs.size(); + } + [[nodiscard]] const std::vector& + unhandled_failures() const noexcept { + return unhandled_failures_; + } + [[nodiscard]] const std::vector& + unavailable_outputs() const noexcept { + return unavailable_outputs_; + } + [[nodiscard]] bool has_unhandled_failures() const noexcept { + return !unhandled_failures_.empty(); + } + [[nodiscard]] bool has_unavailable_outputs() const noexcept { + return !unavailable_outputs_.empty(); + } + + template + [[nodiscard]] flow_node_result result( + const flow_node_handle& node) const { + validate_handle(node.builder_, node.index_); + return decode_result(node.index_); + } + + template + [[nodiscard]] T output(std::size_t ordinal = 0) const { + const auto& projection = output_reference(ordinal); + if (projection.kind != flow_output_kind::outcome) { + throw invalid_state_error( + "Selected flow output is not an outcome projection"); + } + auto settled = decode_result(projection.node_index); + if (settled.status != flow_node_status::succeeded || + !settled.outcome) { + throw invalid_state_error("Selected flow outcome is unavailable"); + } + return std::move(*settled.outcome); + } + + [[nodiscard]] std::optional output_error( + std::size_t ordinal = 0) const { + const auto& projection = output_reference(ordinal); + if (projection.kind != flow_output_kind::error) { + throw invalid_state_error( + "Selected flow output is not an error projection"); + } + return records_[projection.node_index].error; + } + + template + [[nodiscard]] flow_node_result output_result( + std::size_t ordinal = 0) const { + const auto& projection = output_reference(ordinal); + if (projection.kind != flow_output_kind::result) { + throw invalid_state_error( + "Selected flow output is not a result projection"); + } + return decode_result(projection.node_index); + } + + public: + flow_result( + std::shared_ptr definition, + std::vector records, + std::vector node_operation_ids, + std::vector unhandled_failures, + std::vector unavailable_outputs, + std::string durable_execution_arn, std::uint32_t recursive_level) + : definition_(std::move(definition)), + records_(std::move(records)), + node_operation_ids_(std::move(node_operation_ids)), + unhandled_failures_(std::move(unhandled_failures)), + unavailable_outputs_(std::move(unavailable_outputs)), + durable_execution_arn_(std::move(durable_execution_arn)), + recursive_level_(recursive_level) {} + + private: + template + [[nodiscard]] flow_node_result decode_result( + std::size_t index) const { + if (index >= records_.size()) { + throw invalid_state_error("Flow result node index is invalid"); + } + const auto& record = records_[index]; + flow_node_result result{ + .status = record.status, + .error = record.error, + }; + if (record.status == flow_node_status::succeeded && + record.serialized_outcome) { + const auto value = definition_->nodes[index].deserialize_outcome( + *record.serialized_outcome, + serdes_context{ + .operation_id = node_operation_ids_[index], + .durable_execution_arn = durable_execution_arn_, + .recursive_level = recursive_level_, + }); + try { + result.outcome = std::any_cast(value); + } catch (const std::bad_any_cast&) { + throw serialization_error( + "Flow result outcome type does not match its node handle"); + } + } + return result; + } + + void validate_handle( + const std::weak_ptr& builder, + std::size_t index) const { + const auto candidate = builder.lock(); + if (!candidate || candidate.get() != definition_.get() || + index >= records_.size()) { + throw invalid_state_error( + "Flow node handle belongs to another graph"); + } + } + + [[nodiscard]] const detail::flow_output_reference& output_reference( + std::size_t ordinal) const { + if (ordinal >= definition_->outputs.size()) { + throw invalid_state_error("Flow output index is out of range"); + } + return definition_->outputs[ordinal]; + } + + std::shared_ptr definition_; + std::vector records_; + std::vector node_operation_ids_; + std::vector unhandled_failures_; + std::vector unavailable_outputs_; + std::string durable_execution_arn_; + std::uint32_t recursive_level_; + + friend class flow_execution_error; + friend struct detail_flow_access; + friend struct detail::flow_result_serdes; +}; + +class flow_execution_error final : public durable_error { + public: + flow_execution_error(std::string message, flow_result result) + : durable_error(error_code::callable_failed, std::move(message)), + result_(std::make_shared(std::move(result))) {} + + [[nodiscard]] const flow_result& result() const noexcept { + return *result_; + } + + private: + std::shared_ptr result_; +}; + +struct flow_config { + std::optional name; + std::size_t max_concurrency{0}; +}; + +namespace detail { + +enum class flow_evaluation_status { pending, matched, unmatched }; + +struct flow_evaluation { + flow_evaluation_status status{flow_evaluation_status::pending}; + std::vector handled_failures; +}; + +[[nodiscard]] inline bool dependency_matches( + flow_dependency_condition condition, flow_node_status status) noexcept { + if (condition == flow_dependency_condition::completed) return true; + if (condition == flow_dependency_condition::succeeded) { + return status == flow_node_status::succeeded; + } + return status == flow_node_status::failed; +} + +[[nodiscard]] inline flow_evaluation evaluate_dependency( + const std::shared_ptr& tree, + const std::vector>& records) { + if (tree->type == flow_dependency_tree::kind::leaf) { + if (tree->node_index >= records.size() || + !records[tree->node_index]) { + return {}; + } + const auto status = records[tree->node_index]->status; + if (!dependency_matches(tree->condition, status)) { + return flow_evaluation{ + .status = flow_evaluation_status::unmatched, + .handled_failures = {}, + }; + } + flow_evaluation result{ + .status = flow_evaluation_status::matched, + .handled_failures = {}, + }; + if (tree->condition == flow_dependency_condition::failed && + status == flow_node_status::failed) { + result.handled_failures.push_back(tree->node_index); + } + return result; + } + + std::vector children; + children.reserve(tree->children.size()); + for (const auto& child : tree->children) { + children.push_back(evaluate_dependency(child, records)); + } + if (tree->type == flow_dependency_tree::kind::all) { + if (std::ranges::any_of(children, [](const auto& child) { + return child.status == flow_evaluation_status::pending; + })) { + return {}; + } + if (std::ranges::any_of(children, [](const auto& child) { + return child.status == flow_evaluation_status::unmatched; + })) { + return flow_evaluation{ + .status = flow_evaluation_status::unmatched, + .handled_failures = {}, + }; + } + flow_evaluation result{ + .status = flow_evaluation_status::matched, + .handled_failures = {}, + }; + for (auto& child : children) { + result.handled_failures.insert( + result.handled_failures.end(), + child.handled_failures.begin(), + child.handled_failures.end()); + } + return result; + } + + for (auto& child : children) { + if (child.status == flow_evaluation_status::matched) { + return child; + } + } + if (std::ranges::any_of(children, [](const auto& child) { + return child.status == flow_evaluation_status::pending; + })) { + return {}; + } + return flow_evaluation{ + .status = flow_evaluation_status::unmatched, + .handled_failures = {}, + }; +} + +inline void collect_dependency_indices( + const std::shared_ptr& tree, + std::vector& indices) { + if (tree->type == flow_dependency_tree::kind::leaf) { + if (std::ranges::find(indices, tree->node_index) == indices.end()) { + indices.push_back(tree->node_index); + } + return; + } + for (const auto& child : tree->children) { + collect_dependency_indices(child, indices); + } +} + +[[nodiscard]] inline error_object flow_error( + const std::exception& exception) { + auto error = exception_to_error(exception); + if (const auto* callable = + dynamic_cast(&exception); + callable && !callable->type().empty()) { + error.type = callable->type(); + } + return error; +} + +struct flow_node_record_serdes { + [[nodiscard]] std::string serialize( + const flow_node_record& value, const serdes_context&) const { + std::string output; + append_line(output, "DEXFN1"); + append_line(output, to_string(value.status)); + append_bool(output, value.serialized_outcome.has_value()); + if (value.serialized_outcome) { + append_blob(output, *value.serialized_outcome); + } + append_bool(output, value.error.has_value()); + if (value.error) { + append_optional_string(output, value.error->message); + append_optional_string(output, value.error->type); + append_optional_string(output, value.error->data); + append_size(output, value.error->stack_trace.size()); + for (const auto& frame : value.error->stack_trace) { + append_blob(output, frame); + } + } + append_size(output, value.handled_failures.size()); + for (const auto dependency : value.handled_failures) { + append_size(output, dependency); + } + return output; + } + + [[nodiscard]] flow_node_record deserialize( + std::string_view data, const serdes_context&) const { + batch_payload_reader reader{data}; + if (reader.line() != "DEXFN1") { + throw serialization_error( + "Unsupported flow node result payload version"); + } + flow_node_record result; + const auto status = reader.line(); + if (status == "SUCCEEDED") { + result.status = flow_node_status::succeeded; + } else if (status == "FAILED") { + result.status = flow_node_status::failed; + } else if (status == "SKIPPED") { + result.status = flow_node_status::skipped; + } else { + throw serialization_error("Unknown flow node status"); + } + if (reader.boolean()) { + result.serialized_outcome = std::string{reader.blob()}; + } + if (reader.boolean()) { + error_object error; + error.message = read_optional_string(reader); + error.type = read_optional_string(reader); + error.data = read_optional_string(reader); + const auto stack_count = reader.size_value(); + error.stack_trace.reserve(stack_count); + for (std::size_t index = 0; index < stack_count; ++index) { + error.stack_trace.emplace_back(reader.blob()); + } + result.error = std::move(error); + } + const auto handled_count = reader.size_value(); + result.handled_failures.reserve(handled_count); + for (std::size_t index = 0; index < handled_count; ++index) { + result.handled_failures.push_back(reader.size_value()); + } + if (!reader.empty()) { + throw serialization_error( + "Trailing flow node result payload data"); + } + return result; + } +}; + +[[nodiscard]] inline std::vector node_operation_ids( + const flow_builder_state& definition, std::string_view flow_operation_id) { + std::vector ids(definition.nodes.size()); + operation_id_generator generator{std::string{flow_operation_id}}; + for (const auto node : definition.execution_nodes) { + ids[node] = generator.next(); + } + return ids; +} + +struct flow_result_serdes { + std::shared_ptr definition; + + [[nodiscard]] std::string serialize( + const flow_result& value, const serdes_context&) const { + std::string output; + append_line(output, "DEXFL1"); + append_size(output, value.records_.size()); + flow_node_record_serdes node_serdes; + for (std::size_t index = 0; index < value.records_.size(); ++index) { + append_blob(output, definition->nodes[index].name); + append_blob( + output, + node_serdes.serialize(value.records_[index], serdes_context{})); + } + append_size(output, definition->outputs.size()); + for (const auto& projection : definition->outputs) { + append_size(output, projection.node_index); + append_line(output, to_string(projection.kind)); + } + append_size(output, value.unhandled_failures_.size()); + for (const auto& name : value.unhandled_failures_) { + append_blob(output, name); + } + append_size(output, value.unavailable_outputs_.size()); + for (const auto& name : value.unavailable_outputs_) { + append_blob(output, name); + } + return output; + } + + [[nodiscard]] flow_result deserialize( + std::string_view data, const serdes_context& context) const { + batch_payload_reader reader{data}; + if (reader.line() != "DEXFL1") { + throw serialization_error( + "Unsupported flow result payload version"); + } + const auto node_count = reader.size_value(); + if (node_count != definition->nodes.size()) { + throw serialization_error( + "Flow definition node count changed during replay"); + } + flow_node_record_serdes node_serdes; + std::vector records; + records.reserve(node_count); + for (std::size_t index = 0; index < node_count; ++index) { + if (reader.blob() != definition->nodes[index].name) { + throw serialization_error( + "Flow definition node name changed during replay"); + } + records.push_back(node_serdes.deserialize( + reader.blob(), serdes_context{})); + } + const auto output_count = reader.size_value(); + if (output_count != definition->outputs.size()) { + throw serialization_error( + "Flow output selection changed during replay"); + } + for (std::size_t index = 0; index < output_count; ++index) { + const auto node_index = reader.size_value(); + const auto kind = reader.line(); + if (node_index != definition->outputs[index].node_index || + kind != to_string(definition->outputs[index].kind)) { + throw serialization_error( + "Flow output projection changed during replay"); + } + } + std::vector unhandled; + const auto unhandled_count = reader.size_value(); + unhandled.reserve(unhandled_count); + for (std::size_t index = 0; index < unhandled_count; ++index) { + unhandled.emplace_back(reader.blob()); + } + std::vector unavailable; + const auto unavailable_count = reader.size_value(); + unavailable.reserve(unavailable_count); + for (std::size_t index = 0; index < unavailable_count; ++index) { + unavailable.emplace_back(reader.blob()); + } + if (!reader.empty()) { + throw serialization_error("Trailing flow result payload data"); + } + return flow_result{ + definition, std::move(records), + node_operation_ids(*definition, context.operation_id), + std::move(unhandled), std::move(unavailable), + std::string{context.durable_execution_arn}, + context.recursive_level}; + } +}; + +struct suspension_value { + std::string message; + std::optional resume_after; +}; + +[[nodiscard]] inline bool is_control_error( + const durable_error& error) noexcept { + return error.code() == error_code::invalid_state || + error.code() == error_code::checkpoint_failed || + error.code() == error_code::state_fetch_failed || + error.code() == error_code::serialization_failed || + error.code() == error_code::protocol_error; +} + +[[nodiscard]] inline flow_node_record execute_logical_node( + const flow_node_definition& definition, flow_node_context& context, + const serdes_context& serdes_context, + std::vector handled_failures) { + try { + auto result = definition.execute(context, serdes_context); + result.handled_failures = std::move(handled_failures); + return result; + } catch (const execution_suspended&) { + throw; + } catch (const checkpoint_error&) { + throw; + } catch (const state_fetch_error&) { + throw; + } catch (const serialization_error&) { + throw; + } catch (const invalid_state_error&) { + throw; + } catch (const durable_error& error) { + if (is_control_error(error)) throw; + return flow_node_record{ + .status = flow_node_status::failed, + .serialized_outcome = std::nullopt, + .error = flow_error(error), + .handled_failures = std::move(handled_failures), + }; + } catch (const std::exception& error) { + return flow_node_record{ + .status = flow_node_status::failed, + .serialized_outcome = std::nullopt, + .error = flow_error(error), + .handled_failures = std::move(handled_failures), + }; + } +} + +[[nodiscard]] inline flow_result execute_flow( + std::shared_ptr definition, + durable_context& flow_context, std::size_t max_concurrency) { + std::vector> records( + definition->nodes.size()); + std::vector suspended(definition->nodes.size(), false); + for (std::size_t index = 0; index < definition->nodes.size(); ++index) { + if (!definition->execution_ordinals[index]) { + records[index] = flow_node_record{ + .status = flow_node_status::skipped, + .serialized_outcome = std::nullopt, + .error = std::nullopt, + .handled_failures = {}, + }; + } + } + + const std::uint64_t base_index = + flow_context.current_operation_index(); + std::vector operation_ids(definition->nodes.size()); + for (const auto node : definition->execution_nodes) { + auto reservation = flow_context.reserve_operation( + operation_subtype::run_in_child_context, + operation_type::context, definition->nodes[node].name); + operation_ids[node] = reservation.require_operation_id(); + } + + std::vector suspensions; + while (true) { + struct ready_node { + std::size_t index; + bool matched; + std::vector handled_failures; + std::vector direct_dependencies; + }; + std::vector ready; + for (const auto node_index : definition->execution_nodes) { + if (records[node_index] || suspended[node_index]) continue; + const auto& dependency = + definition->nodes[node_index].dependency; + if (!dependency) { + ready.push_back(ready_node{ + .index = node_index, + .matched = true, + .handled_failures = {}, + .direct_dependencies = {}, + }); + continue; + } + auto evaluation = + evaluate_dependency(dependency, records); + if (evaluation.status == flow_evaluation_status::pending) { + continue; + } + std::vector direct_dependencies; + collect_dependency_indices( + dependency, direct_dependencies); + ready.push_back(ready_node{ + .index = node_index, + .matched = + evaluation.status == flow_evaluation_status::matched, + .handled_failures = + std::move(evaluation.handled_failures), + .direct_dependencies = + std::move(direct_dependencies), + }); + } + + if (ready.empty()) { + const bool complete = std::ranges::all_of( + definition->execution_nodes, [&](std::size_t index) { + return records[index].has_value(); + }); + if (complete) break; + if (!suspensions.empty()) { + const auto selected = std::ranges::min_element( + suspensions, [](const auto& lhs, const auto& rhs) { + if (!lhs.resume_after) return false; + if (!rhs.resume_after) return true; + return *lhs.resume_after < *rhs.resume_after; + }); + throw execution_suspended( + selected->message, selected->resume_after); + } + throw invalid_state_error( + "Flow cannot make progress despite passing cycle validation"); + } + + std::atomic next{0}; + std::mutex result_mutex; + std::exception_ptr fatal_error; + auto worker = [&] { + while (true) { + const auto ready_index = + next.fetch_add(1U, std::memory_order_relaxed); + if (ready_index >= ready.size()) return; + const auto& selected = ready[ready_index]; + const auto ordinal = + *definition->execution_ordinals[selected.index]; + auto node_parent = flow_context.fork_at( + base_index + static_cast(ordinal)); + scoped_context binding{node_parent}; + try { + auto record = run_in_child_context( + [&]() { + if (!selected.matched) { + return flow_node_record{ + .status = flow_node_status::skipped, + .serialized_outcome = std::nullopt, + .error = std::nullopt, + .handled_failures = {}, + }; + } + flow_node_context node_context{ + definition, selected.index, records, + selected.direct_dependencies, operation_ids, + flow_context.state().durable_execution_arn(), + flow_context.state().recursive_level()}; + return execute_logical_node( + definition->nodes[selected.index], + node_context, + serdes_context{ + .operation_id = operation_ids[selected.index], + .durable_execution_arn = + flow_context.state().durable_execution_arn(), + .recursive_level = + flow_context.state().recursive_level(), + }, + selected.handled_failures); + }, + flow_node_record_serdes{}, + child_context_config{ + .name = definition->nodes[selected.index].name, + }); + std::lock_guard lock{result_mutex}; + records[selected.index] = std::move(record); + } catch (const execution_suspended& suspension) { + std::lock_guard lock{result_mutex}; + suspended[selected.index] = true; + suspensions.push_back(suspension_value{ + .message = suspension.what(), + .resume_after = suspension.resume_after(), + }); + } catch (...) { + std::lock_guard lock{result_mutex}; + if (!fatal_error) fatal_error = std::current_exception(); + } + } + }; + + auto batching = + flow_context.state().enable_checkpoint_batching(); + const auto count = worker_count( + ready.size(), max_concurrency); + std::vector workers; + workers.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + workers.emplace_back(worker); + } + workers.clear(); + if (fatal_error) std::rethrow_exception(fatal_error); + } + + std::vector settled; + settled.reserve(records.size()); + for (auto& record : records) { + if (!record) { + throw invalid_state_error("Flow result contains an unsettled node"); + } + settled.push_back(std::move(*record)); + } + + std::unordered_set handled; + for (const auto& record : settled) { + handled.insert( + record.handled_failures.begin(), + record.handled_failures.end()); + } + for (const auto& output : definition->outputs) { + if ((output.kind == flow_output_kind::error || + output.kind == flow_output_kind::result) && + settled[output.node_index].status == + flow_node_status::failed) { + handled.insert(output.node_index); + } + } + + std::vector unhandled; + for (std::size_t index = 0; index < settled.size(); ++index) { + if (settled[index].status == flow_node_status::failed && + !handled.contains(index)) { + unhandled.push_back(definition->nodes[index].name); + } + } + std::vector unavailable; + for (const auto& output : definition->outputs) { + if (output.kind == flow_output_kind::outcome && + settled[output.node_index].status != + flow_node_status::succeeded && + std::ranges::find( + unavailable, definition->nodes[output.node_index].name) == + unavailable.end()) { + unavailable.push_back( + definition->nodes[output.node_index].name); + } + } + + return flow_result{ + definition, std::move(settled), std::move(operation_ids), + std::move(unhandled), std::move(unavailable), + flow_context.state().durable_execution_arn(), + flow_context.state().recursive_level()}; +} + +} // namespace detail + +[[nodiscard]] inline flow_result flow( + flow_builder builder, flow_config config = {}) { + const auto definition = builder.freeze(); + const std::string name = config.name.value_or("flow"); + detail::flow_result_serdes serializer{definition}; + auto result = run_in_child_context( + [&](durable_context& context) { + return detail::execute_flow( + definition, context, config.max_concurrency); + }, + serializer, + child_context_config{.name = name}); + + std::string problem; + if (result.has_unhandled_failures()) { + problem = "unhandled node failures"; + } + if (result.has_unavailable_outputs()) { + if (!problem.empty()) problem.append("; "); + problem.append("unavailable outcome outputs"); + } + if (!problem.empty()) { + throw flow_execution_error( + "Flow has " + problem, std::move(result)); + } + return result; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/handler.hpp b/include/aws/durable_execution/handler.hpp new file mode 100644 index 0000000..9bace68 --- /dev/null +++ b/include/aws/durable_execution/handler.hpp @@ -0,0 +1,276 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/execution_state.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/plugin.hpp" +#include "aws/durable_execution/serdes.hpp" +#include "aws/durable_execution/service_client.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +inline constexpr std::size_t lambda_response_size_limit = + 6U * 1024U * 1024U - 50U; + +namespace detail { + +template +concept durable_handler_function = + std::invocable || + std::invocable || + std::invocable || + std::invocable; + +template +decltype(auto) invoke_handler( + Function&& function, durable_context& context, std::string_view input) { + if constexpr (std::invocable) { + return std::invoke(function, context, input); + } else if constexpr (std::invocable) { + return std::invoke(function, input); + } else if constexpr (std::invocable) { + return std::invoke(function, context); + } else if constexpr (std::invocable) { + return std::invoke(function); + } else { + static_assert( + std::invocable, + "A durable handler must accept (durable_context&, string_view), " + "string_view, durable_context&, or no arguments"); + } +} + +template +using handler_result_t = std::remove_cvref_t(), std::declval(), + std::declval()))>; + +template +using handler_value_t = std::conditional_t< + std::is_void_v>, std::monostate, + handler_result_t>; + +[[nodiscard]] inline error_object make_handler_error( + std::string message, std::string type) { + return error_object{ + .message = std::move(message), + .type = std::move(type), + .data = std::nullopt, + .stack_trace = {}, + }; +} + +} // namespace detail + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> +[[nodiscard]] invocation_output run( + const invocation_input& input, service_client& client, + const lambda_invocation_metadata& invocation_metadata, + Function&& function, Serializer serializer, + const run_options& options) { + using raw_result = detail::handler_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, raw_result>; + static_assert( + serializer_for, + "The supplied handler serializer does not satisfy serializer_for"); + + detail::plugin_manager plugins{options}; + execution_state state{ + input.durable_execution_arn, input.checkpoint_token, client, {}, + invocation_metadata, &plugins}; + bool invocation_started = false; + bool is_first_invocation = true; + std::optional execution_start_timestamp; + const std::string_view request_id = + options.request_id.empty() + ? std::string_view{input.checkpoint_token} + : std::string_view{options.request_id}; + const auto finish = [&](invocation_output output) { + if (invocation_started) { + const auto operations = state.operation_snapshots(); + plugins.invocation_end( + request_id, + state.durable_execution_arn(), state.input_payload(), + operations, execution_start_timestamp, output.status, + output.result, output.error, is_first_invocation); + } + return output; + }; + try { + state.initialize(input.initial_state); + const auto root_operation = state.execution_operation(); + if (!root_operation) { + throw invalid_state_error( + "Execution state is missing the root execution operation"); + } + if (!root_operation->status.is_known()) { + throw invalid_state_error( + "Root execution has an unknown future status: " + + std::string{root_operation->status.wire_value()}); + } + is_first_invocation = !state.has_prior_operations(); + execution_start_timestamp = root_operation->start_timestamp; + const auto operations = state.operation_snapshots(); + const auto updated_operations = + state.operation_snapshots(input.updated_operation_ids); + plugins.invocation_start( + request_id, + state.durable_execution_arn(), state.input_payload(), operations, + updated_operations, execution_start_timestamp, + is_first_invocation); + invocation_started = true; + state.notify_external_updates(input.updated_operation_ids); + + durable_context root{ + state, operation_identifier::execution(), std::nullopt, + state.has_prior_operations()}; + scoped_context binding{root}; + + result_type result; + if constexpr (std::is_void_v) { + detail::invoke_handler(function, root, state.input_payload()); + result = {}; + } else { + result = detail::invoke_handler(function, root, state.input_payload()); + } + + auto serialized = serializer.serialize( + result, + serdes_context{ + .operation_id = {}, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + if (serialized.size() > lambda_response_size_limit) { + state.checkpoint(operation_update::execution_succeed(serialized)); + serialized.clear(); + } + return finish(invocation_output{ + .status = invocation_status::succeeded, + .result = std::move(serialized), + .error = std::nullopt, + }); + } catch (const execution_suspended&) { + return finish(invocation_output{ + .status = invocation_status::pending, + .result = std::nullopt, + .error = std::nullopt, + }); + } catch (const checkpoint_error& error) { + return finish(invocation_output{ + .status = + error.retryable() ? invocation_status::retry : invocation_status::failed, + .result = std::nullopt, + .error = detail::make_handler_error(error.what(), "CheckpointError"), + }); + } catch (const state_fetch_error& error) { + return finish(invocation_output{ + .status = + error.retryable() ? invocation_status::retry : invocation_status::failed, + .result = std::nullopt, + .error = detail::make_handler_error(error.what(), "StateFetchError"), + }); + } catch (const durable_error& error) { + return finish(invocation_output{ + .status = invocation_status::failed, + .result = std::nullopt, + .error = detail::make_handler_error(error.what(), "DurableExecutionError"), + }); + } catch (const std::exception& error) { + return finish(invocation_output{ + .status = invocation_status::failed, + .result = std::nullopt, + .error = detail::make_handler_error(error.what(), typeid(error).name()), + }); + } catch (...) { + return finish(invocation_output{ + .status = invocation_status::failed, + .result = std::nullopt, + .error = detail::make_handler_error( + "Unknown non-standard exception", "unknown"), + }); + } +} + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> +[[nodiscard]] invocation_output run( + const invocation_input& input, service_client& client, + const lambda_invocation_metadata& invocation_metadata, + Function&& function, Serializer serializer) { + return run( + input, client, invocation_metadata, + std::forward(function), std::move(serializer), + run_options{}); +} + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> +[[nodiscard]] invocation_output run( + const invocation_input& input, service_client& client, Function&& function, + Serializer serializer, const run_options& options) { + return run( + input, client, lambda_invocation_metadata{}, + std::forward(function), std::move(serializer), options); +} + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> +[[nodiscard]] invocation_output run( + const invocation_input& input, service_client& client, Function&& function, + Serializer serializer) { + return run( + input, client, std::forward(function), + std::move(serializer), run_options{}); +} + +template + requires detail::durable_handler_function +[[nodiscard]] invocation_output run( + const invocation_input& input, service_client& client, Function&& function) { + using raw_result = detail::handler_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, raw_result>; + return run( + input, client, std::forward(function), + default_serdes{}); +} + +template + requires detail::durable_handler_function +[[nodiscard]] invocation_output run( + const invocation_input& input, service_client& client, + const lambda_invocation_metadata& invocation_metadata, + Function&& function) { + using raw_result = detail::handler_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, raw_result>; + return run( + input, client, invocation_metadata, + std::forward(function), + default_serdes{}); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/invoke.hpp b/include/aws/durable_execution/invoke.hpp new file mode 100644 index 0000000..ac466ec --- /dev/null +++ b/include/aws/durable_execution/invoke.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/execution_state.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct invoke_config { + std::optional name; + std::optional tenant_id; +}; + +template < + typename Result = std::string, typename Payload, + typename PayloadSerializer = + default_serdes>, + typename ResultSerializer = default_serdes> + requires serializer_for> && + serializer_for +[[nodiscard]] std::optional invoke( + std::string_view function_name, const Payload& payload, + invoke_config config = {}, PayloadSerializer payload_serializer = {}, + ResultSerializer result_serializer = {}) { + if (function_name.empty()) { + throw durable_error( + error_code::invalid_argument, + "chained invoke function name must not be empty"); + } + + auto& context = current_context(); + const auto identifier = context.reserve_operation( + operation_subtype::chained_invoke, operation_type::chained_invoke, + config.name ? std::optional{*config.name} + : std::nullopt); + const auto& id = identifier.require_operation_id(); + auto& state = context.state(); + const auto existing = state.find_operation(id); + + if (existing) { + detail::validate_replay_identity(*existing, identifier); + if (!existing->status.is_known()) { + throw invalid_state_error( + "Chained invoke has an unknown future status: " + + std::string{existing->status.wire_value()}); + } + context.before_operation(id, false); + + if (existing->status == operation_status::succeeded) { + if (!existing->chained_invoke || + !existing->chained_invoke->result) { + return std::nullopt; + } + return result_serializer.deserialize( + *existing->chained_invoke->result, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + } + + if (existing->status == operation_status::failed || + existing->status == operation_status::cancelled || + existing->status == operation_status::timed_out || + existing->status == operation_status::stopped) { + const error_object* error = + existing->chained_invoke && existing->chained_invoke->error + ? &*existing->chained_invoke->error + : nullptr; + throw callable_error( + error && error->message + ? *error->message + : "Chained invoke failed without an ErrorObject", + error && error->type ? *error->type : std::string{}); + } + + throw execution_suspended( + "Chained invoke is still in progress: " + id); + } + + context.before_operation(id, false); + const auto serialized_payload = payload_serializer.serialize( + payload, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + state.checkpoint(operation_update::chained_invoke_start( + identifier, serialized_payload, + chained_invoke_options{ + .function_name = std::string{function_name}, + .tenant_id = std::move(config.tenant_id), + })); + throw execution_suspended("Chained invoke started: " + id); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/lambda_runtime.hpp b/include/aws/durable_execution/lambda_runtime.hpp new file mode 100644 index 0000000..3f3f7dd --- /dev/null +++ b/include/aws/durable_execution/lambda_runtime.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "aws/durable_execution/runtime.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +template + requires std::copy_constructible> && + std::copy_constructible> +[[nodiscard]] auto make_lambda_handler( + service_client& client, Function&& function, Serializer&& serializer, + run_options options) { + return [ + &client, + durable_function = std::forward(function), + durable_serializer = std::forward(serializer), + durable_options = std::move(options)]( + const ::aws::lambda_runtime::invocation_request& request) mutable + -> ::aws::lambda_runtime::invocation_response { + lambda_invocation_metadata invocation_metadata; + if (!request.function_arn.empty()) { + invocation_metadata.invoked_function_arn = request.function_arn; + } + if (!request.tenant_id.empty()) { + invocation_metadata.tenant_id = request.tenant_id; + } + auto invocation_options = durable_options; + invocation_options.request_id = request.request_id; + auto response = run_json( + request.payload, client, invocation_metadata, durable_function, + durable_serializer, invocation_options); + if (!response) { + return ::aws::lambda_runtime::invocation_response::failure( + response.error().message + " at byte " + + std::to_string(response.error().offset), + "DurableExecutionWireError"); + } + return ::aws::lambda_runtime::invocation_response::success( + std::move(*response), "application/json"); + }; +} + +template + requires std::copy_constructible> && + std::copy_constructible> +[[nodiscard]] auto make_lambda_handler( + service_client& client, Function&& function, Serializer&& serializer) { + return make_lambda_handler( + client, std::forward(function), + std::forward(serializer), run_options{}); +} + +template + requires std::copy_constructible> +[[nodiscard]] auto make_lambda_handler( + service_client& client, Function&& function) { + using function_type = std::remove_cvref_t; + using raw_result = detail::handler_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, raw_result>; + return make_lambda_handler( + client, std::forward(function), + default_serdes{}); +} + +template + requires std::copy_constructible> +[[nodiscard]] auto make_lambda_handler( + service_client& client, Function&& function, run_options options) { + using function_type = std::remove_cvref_t; + using raw_result = detail::handler_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, raw_result>; + return make_lambda_handler( + client, std::forward(function), + default_serdes{}, std::move(options)); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/local_runner.hpp b/include/aws/durable_execution/local_runner.hpp new file mode 100644 index 0000000..9e9483c --- /dev/null +++ b/include/aws/durable_execution/local_runner.hpp @@ -0,0 +1,262 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/handler.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/serdes.hpp" +#include "aws/durable_execution/service_client.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +enum class local_run_status { + succeeded, + failed, + pending_external, + deadlocked, + invocation_limit_exceeded, + timed_out, +}; + +struct local_runner_options { + std::string input_json{"null"}; + std::string execution_name{"local-execution"}; + std::string function_name{"local-function"}; + std::string function_version{"$LATEST"}; + std::optional tenant_id; + std::size_t max_invocations{1'000}; + std::chrono::seconds execution_timeout{900}; + bool auto_advance_callback_timeouts{false}; + timestamp virtual_start_time{}; +}; + +class local_test_result { + public: + [[nodiscard]] local_run_status status() const noexcept { return status_; } + [[nodiscard]] const invocation_output& output() const noexcept { + return output_; + } + [[nodiscard]] const std::vector& operations() const noexcept { + return operations_; + } + [[nodiscard]] const std::vector& updates() const noexcept { + return updates_; + } + [[nodiscard]] std::size_t invocation_count() const noexcept { + return invocation_count_; + } + [[nodiscard]] timestamp virtual_time() const noexcept { + return virtual_time_; + } + [[nodiscard]] const std::string& pending_reason() const noexcept { + return pending_reason_; + } + + [[nodiscard]] const operation* operation_by_id( + std::string_view operation_id) const noexcept; + [[nodiscard]] const operation* operation_by_name( + std::string_view name) const noexcept; + [[nodiscard]] const operation* step( + std::string_view name) const noexcept; + [[nodiscard]] const operation* wait( + std::string_view name) const noexcept; + [[nodiscard]] std::vector pending_callback_ids() const; + + template < + typename T, + typename Serializer = default_serdes> + requires serializer_for + [[nodiscard]] T deserialize_result(Serializer serializer = {}) const { + if (!output_.result) { + throw invalid_state_error( + "Local execution has no result payload"); + } + return serializer.deserialize( + *output_.result, + serdes_context{ + .operation_id = {}, + .durable_execution_arn = execution_arn_, + .recursive_level = 0, + }); + } + + private: + local_test_result( + local_run_status status, invocation_output output, + std::vector operations, + std::vector updates, + std::size_t invocation_count, timestamp virtual_time, + std::string pending_reason, std::string execution_arn) + : status_(status), + output_(std::move(output)), + operations_(std::move(operations)), + updates_(std::move(updates)), + invocation_count_(invocation_count), + virtual_time_(virtual_time), + pending_reason_(std::move(pending_reason)), + execution_arn_(std::move(execution_arn)) {} + + local_run_status status_; + invocation_output output_; + std::vector operations_; + std::vector updates_; + std::size_t invocation_count_; + timestamp virtual_time_; + std::string pending_reason_; + std::string execution_arn_; + + friend class local_runner; +}; + +class local_service_client final : public service_client { + public: + explicit local_service_client(local_runner_options options); + ~local_service_client() override; + + local_service_client(const local_service_client&) = delete; + local_service_client& operator=(const local_service_client&) = delete; + local_service_client(local_service_client&&) noexcept; + local_service_client& operator=(local_service_client&&) noexcept; + + [[nodiscard]] std::expected checkpoint( + const checkpoint_request& request) override; + [[nodiscard]] std::expected + get_execution_state(const get_state_request& request) override; + + [[nodiscard]] invocation_input invocation(); + [[nodiscard]] const lambda_invocation_metadata& invocation_metadata() + const noexcept; + [[nodiscard]] std::string execution_arn() const; + [[nodiscard]] timestamp virtual_time() const; + [[nodiscard]] std::vector operations() const; + [[nodiscard]] std::vector updates() const; + [[nodiscard]] std::vector pending_callback_ids() const; + [[nodiscard]] bool has_external_work() const; + + void complete_invocation(const invocation_output& output); + [[nodiscard]] bool advance_next_automatic_event( + bool include_callback_timeouts); + void advance_time(std::chrono::seconds duration); + + void send_callback_success( + std::string_view callback_id, + std::optional serialized_result = std::nullopt); + void send_callback_failure( + std::string_view callback_id, error_object error); + void send_callback_heartbeat(std::string_view callback_id); + + void mock_invoke_success( + std::string function_name, std::string serialized_result); + void mock_invoke_failure( + std::string function_name, error_object error); + + [[nodiscard]] std::optional terminal_output() const; + + private: + struct impl; + std::unique_ptr impl_; +}; + +class local_runner { + public: + using handler_type = std::function; + + local_runner(handler_type handler, local_runner_options options = {}); + + local_runner(const local_runner&) = delete; + local_runner& operator=(const local_runner&) = delete; + local_runner(local_runner&&) noexcept = default; + local_runner& operator=(local_runner&&) noexcept = default; + + [[nodiscard]] local_test_result run(); + [[nodiscard]] local_test_result resume() { return run(); } + + void send_callback_success( + std::string_view callback_id, + std::optional serialized_result = std::nullopt); + void send_callback_failure( + std::string_view callback_id, error_object error); + void send_callback_heartbeat(std::string_view callback_id); + void advance_time(std::chrono::seconds duration); + void mock_invoke_success( + std::string function_name, std::string serialized_result); + void mock_invoke_failure( + std::string function_name, error_object error); + + [[nodiscard]] local_service_client& service() noexcept { + return service_; + } + + private: + [[nodiscard]] local_test_result snapshot( + local_run_status status, invocation_output output, + std::string pending_reason = {}) const; + + handler_type handler_; + local_runner_options options_; + local_service_client service_; + std::size_t invocation_count_{0}; +}; + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> && + std::copy_constructible> && + std::copy_constructible +[[nodiscard]] local_runner make_local_runner( + Function function, local_runner_options options, + Serializer serializer, run_options run_configuration) { + return local_runner{ + [function = std::move(function), + serializer = std::move(serializer), + run_configuration = std::move(run_configuration)]( + const invocation_input& input, service_client& client, + const lambda_invocation_metadata& metadata) mutable { + return run( + input, client, metadata, function, serializer, + run_configuration); + }, + std::move(options)}; +} + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> && + std::copy_constructible> && + std::copy_constructible +[[nodiscard]] local_runner make_local_runner( + Function function, local_runner_options options, + Serializer serializer) { + return make_local_runner( + std::move(function), std::move(options), std::move(serializer), + run_options{}); +} + +template + requires detail::durable_handler_function && + std::copy_constructible> +[[nodiscard]] local_runner make_local_runner( + Function function, local_runner_options options = {}) { + using result_type = detail::handler_value_t; + return make_local_runner( + std::move(function), std::move(options), + default_serdes{}); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/model.hpp b/include/aws/durable_execution/model.hpp new file mode 100644 index 0000000..dafbe10 --- /dev/null +++ b/include/aws/durable_execution/model.hpp @@ -0,0 +1,335 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace aws::durable_execution { +inline namespace v1 { + +using timestamp = std::chrono::system_clock::time_point; + +enum class operation_action { start, succeed, fail, retry, cancel }; +enum class operation_status { + started, + pending, + ready, + succeeded, + failed, + cancelled, + timed_out, + stopped, +}; +enum class operation_type { + execution, + context, + step, + wait, + callback, + chained_invoke, +}; +enum class invocation_status { succeeded, failed, pending, retry }; +enum class step_semantics { at_most_once_per_retry, at_least_once_per_retry }; +enum class jitter_strategy { none, full, half }; + +template + requires std::is_enum_v +class extensible_enum { + public: + extensible_enum(Enum value) noexcept : known_(value) {} + + [[nodiscard]] static extensible_enum unknown(std::string wire_value) { + extensible_enum result; + result.unknown_ = std::move(wire_value); + return result; + } + + [[nodiscard]] bool is_known() const noexcept { return known_.has_value(); } + [[nodiscard]] std::optional known() const noexcept { return known_; } + [[nodiscard]] std::string_view wire_value() const noexcept; + + [[nodiscard]] friend bool operator==( + const extensible_enum& lhs, Enum rhs) noexcept { + return lhs.known_ == rhs; + } + [[nodiscard]] friend bool operator==( + Enum lhs, const extensible_enum& rhs) noexcept { + return rhs == lhs; + } + + private: + extensible_enum() = default; + + std::optional known_; + std::string unknown_; +}; + +using operation_type_value = extensible_enum; +using operation_status_value = extensible_enum; + +namespace operation_subtype { +inline constexpr std::string_view step = "Step"; +inline constexpr std::string_view wait = "Wait"; +inline constexpr std::string_view callback = "Callback"; +inline constexpr std::string_view run_in_child_context = "RunInChildContext"; +inline constexpr std::string_view map = "Map"; +inline constexpr std::string_view map_iteration = "MapIteration"; +inline constexpr std::string_view parallel = "Parallel"; +inline constexpr std::string_view parallel_branch = "ParallelBranch"; +inline constexpr std::string_view wait_for_callback = "WaitForCallback"; +inline constexpr std::string_view wait_for_condition = "WaitForCondition"; +inline constexpr std::string_view chained_invoke = "ChainedInvoke"; +inline constexpr std::string_view execution = "Execution"; +} // namespace operation_subtype + +[[nodiscard]] constexpr std::string_view to_string(operation_action value) noexcept; +[[nodiscard]] constexpr std::string_view to_string(operation_status value) noexcept; +[[nodiscard]] constexpr std::string_view to_string(operation_type value) noexcept; +[[nodiscard]] constexpr std::string_view to_string(invocation_status value) noexcept; +[[nodiscard]] operation_type_value operation_type_from_wire(std::string_view value); +[[nodiscard]] operation_status_value operation_status_from_wire( + std::string_view value); + +struct error_object { + std::optional message; + std::optional type; + std::optional data; + std::vector stack_trace; +}; + +struct operation_identifier { + std::optional operation_id; + std::string sub_type; + std::optional parent_id; + std::optional name; + std::optional type; + + [[nodiscard]] const std::string& require_operation_id() const; + [[nodiscard]] static operation_identifier execution(); +}; + +struct execution_details { + std::optional input_payload; +}; + +struct context_details { + bool replay_children{false}; + std::optional result; + std::optional error; +}; + +struct step_details { + std::uint32_t attempt{0}; + std::optional next_attempt_timestamp; + std::optional result; + std::optional error; +}; + +struct wait_details { + std::optional scheduled_end_timestamp; +}; + +struct callback_details { + std::string callback_id; + std::optional result; + std::optional error; +}; + +struct chained_invoke_details { + std::optional result; + std::optional error; +}; + +struct operation { + std::string operation_id; + operation_type_value type{operation_type::execution}; + operation_status_value status{operation_status::started}; + std::optional parent_id; + std::optional name; + std::optional start_timestamp; + std::optional end_timestamp; + std::optional sub_type; + std::optional execution; + std::optional context; + std::optional step; + std::optional wait; + std::optional callback; + std::optional chained_invoke; +}; + +struct context_options { + bool replay_children{false}; +}; + +struct step_options { + std::uint32_t next_attempt_delay_seconds{0}; +}; + +struct wait_options { + std::uint32_t wait_seconds{1}; +}; + +struct callback_options { + std::uint32_t timeout_seconds{0}; + std::uint32_t heartbeat_timeout_seconds{0}; +}; + +struct chained_invoke_options { + std::string function_name; + std::optional tenant_id; +}; + +struct operation_update { + std::string operation_id; + operation_type type{operation_type::execution}; + operation_action action{operation_action::start}; + std::optional parent_id; + std::optional name; + std::optional sub_type; + std::optional payload; + std::optional error; + std::optional context; + std::optional step; + std::optional wait; + std::optional callback; + std::optional chained_invoke; + + [[nodiscard]] static operation_update step_start( + const operation_identifier& identifier); + [[nodiscard]] static operation_update step_succeed( + const operation_identifier& identifier, std::string payload); + [[nodiscard]] static operation_update step_fail( + const operation_identifier& identifier, error_object error); + [[nodiscard]] static operation_update step_retry( + const operation_identifier& identifier, + std::optional error, std::uint32_t delay_seconds, + std::optional payload = std::nullopt); + [[nodiscard]] static operation_update wait_start( + const operation_identifier& identifier, std::uint32_t seconds); + [[nodiscard]] static operation_update callback_start( + const operation_identifier& identifier, callback_options options); + [[nodiscard]] static operation_update chained_invoke_start( + const operation_identifier& identifier, std::string payload, + chained_invoke_options options); + [[nodiscard]] static operation_update context_start( + const operation_identifier& identifier); + [[nodiscard]] static operation_update context_succeed( + const operation_identifier& identifier, std::string payload, + bool replay_children = false); + [[nodiscard]] static operation_update context_fail( + const operation_identifier& identifier, error_object error); + [[nodiscard]] static operation_update execution_succeed(std::string payload); + [[nodiscard]] static operation_update execution_fail(error_object error); +}; + +struct initial_execution_state { + std::vector operations; + std::optional next_marker; +}; + +struct lambda_invocation_metadata { + std::optional function_name; + std::optional function_version; + std::optional invoked_function_arn; + std::optional tenant_id; +}; + +struct invocation_input { + std::string durable_execution_arn; + std::string checkpoint_token; + initial_execution_state initial_state; + std::vector updated_operation_ids; +}; + +struct invocation_output { + invocation_status status{invocation_status::succeeded}; + std::optional result; + std::optional error; +}; + +struct checkpoint_request { + std::string_view durable_execution_arn; + std::string_view checkpoint_token; + std::span updates; + std::optional client_token; +}; + +struct checkpoint_output { + std::optional checkpoint_token; + std::vector operations; + std::optional next_marker; +}; + +struct get_state_request { + std::string_view durable_execution_arn; + std::string_view checkpoint_token; + std::string_view marker; + std::uint32_t max_items{1000}; +}; + +struct state_output { + std::vector operations; + std::optional next_marker; +}; + +constexpr std::string_view to_string(operation_action value) noexcept { + switch (value) { + case operation_action::start: return "START"; + case operation_action::succeed: return "SUCCEED"; + case operation_action::fail: return "FAIL"; + case operation_action::retry: return "RETRY"; + case operation_action::cancel: return "CANCEL"; + } + return {}; +} + +constexpr std::string_view to_string(operation_status value) noexcept { + switch (value) { + case operation_status::started: return "STARTED"; + case operation_status::pending: return "PENDING"; + case operation_status::ready: return "READY"; + case operation_status::succeeded: return "SUCCEEDED"; + case operation_status::failed: return "FAILED"; + case operation_status::cancelled: return "CANCELLED"; + case operation_status::timed_out: return "TIMED_OUT"; + case operation_status::stopped: return "STOPPED"; + } + return {}; +} + +constexpr std::string_view to_string(operation_type value) noexcept { + switch (value) { + case operation_type::execution: return "EXECUTION"; + case operation_type::context: return "CONTEXT"; + case operation_type::step: return "STEP"; + case operation_type::wait: return "WAIT"; + case operation_type::callback: return "CALLBACK"; + case operation_type::chained_invoke: return "CHAINED_INVOKE"; + } + return {}; +} + +constexpr std::string_view to_string(invocation_status value) noexcept { + switch (value) { + case invocation_status::succeeded: return "SUCCEEDED"; + case invocation_status::failed: return "FAILED"; + case invocation_status::pending: return "PENDING"; + case invocation_status::retry: return "RETRY"; + } + return {}; +} + +template + requires std::is_enum_v +std::string_view extensible_enum::wire_value() const noexcept { + return known_ ? to_string(*known_) : std::string_view{unknown_}; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/operations.hpp b/include/aws/durable_execution/operations.hpp new file mode 100644 index 0000000..b5d30ae --- /dev/null +++ b/include/aws/durable_execution/operations.hpp @@ -0,0 +1,442 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__GNUG__) +#include +#endif + +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/execution_state.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct retry_strategy { + std::uint32_t max_attempts{6}; + std::chrono::seconds initial_delay{5}; + std::chrono::seconds max_delay{60}; + double backoff_rate{2.0}; + jitter_strategy jitter{jitter_strategy::full}; + std::optional increment; + + [[nodiscard]] std::optional delay_for( + std::uint32_t attempts_made) const { + if (attempts_made >= max_attempts) { + return std::nullopt; + } + + double base = 0.0; + if (increment) { + base = static_cast(initial_delay.count()) + + static_cast(increment->count()) * + static_cast(attempts_made - 1U); + } else { + base = static_cast(initial_delay.count()) * + std::pow(backoff_rate, static_cast(attempts_made - 1U)); + } + base = std::min(base, static_cast(max_delay.count())); + + if (jitter != jitter_strategy::none) { + thread_local std::minstd_rand generator{std::random_device{}()}; + std::uniform_real_distribution distribution(0.0, 1.0); + const double sample = distribution(generator); + base = jitter == jitter_strategy::half + ? (base / 2.0) + sample * (base / 2.0) + : sample * base; + } + const auto seconds = static_cast(std::ceil(base)); + return std::chrono::seconds{std::max(1, seconds)}; + } + + [[nodiscard]] static retry_strategy none() { + retry_strategy strategy; + strategy.max_attempts = 1; + strategy.jitter = jitter_strategy::none; + return strategy; + } +}; + +struct step_config { + std::optional name; + retry_strategy retry{}; + std::function( + const std::exception&, std::uint32_t)> + retry_decider; + step_semantics semantics{step_semantics::at_least_once_per_retry}; +}; + +namespace detail { + +template < + typename Function, + bool WithAttempt = std::invocable> +struct step_callable_result; + +template +struct step_callable_result { + using type = std::invoke_result_t; +}; + +template +struct step_callable_result { + using type = std::invoke_result_t; +}; + +template +using step_callable_result_t = + typename step_callable_result::type; + +template +decltype(auto) invoke_step( + Function& function, std::uint32_t attempt) { + if constexpr (std::invocable) { + return std::invoke(function, attempt); + } else { + return std::invoke(function); + } +} + +[[nodiscard]] inline std::string exception_type_name( + const std::exception& error) { +#if defined(__GNUG__) + int status = 0; + char* demangled = + abi::__cxa_demangle(typeid(error).name(), nullptr, nullptr, &status); + if (status == 0 && demangled) { + std::string result{demangled}; + std::free(demangled); + return result; + } + std::free(demangled); +#endif + return typeid(error).name(); +} + +[[nodiscard]] inline error_object exception_to_error( + const std::exception& error) { + return error_object{ + .message = std::string{error.what()}, + .type = exception_type_name(error), + .data = std::nullopt, + .stack_trace = {}, + }; +} + +inline void validate_replay_identity( + const operation& existing, const operation_identifier& expected) { + if (expected.type && existing.type != *expected.type) { + throw invalid_state_error( + "Durable operation type changed for operation " + existing.operation_id); + } + if (existing.sub_type != std::optional{expected.sub_type}) { + throw invalid_state_error( + "Durable operation subtype changed for operation " + existing.operation_id); + } + if (existing.name != expected.name) { + throw invalid_state_error( + "Durable operation name changed for operation " + existing.operation_id); + } + if (existing.parent_id != expected.parent_id) { + throw invalid_state_error( + "Durable operation parent changed for operation " + existing.operation_id); + } +} + +[[noreturn]] inline void suspend_pending_step( + const operation_identifier& identifier, const operation& existing) { + std::optional resume_after; + if (existing.step && existing.step->next_attempt_timestamp) { + const auto remaining = std::chrono::duration_cast( + *existing.step->next_attempt_timestamp - std::chrono::system_clock::now()); + resume_after = std::max(remaining, std::chrono::seconds::zero()); + } + throw execution_suspended( + "Retry pending for durable step " + identifier.require_operation_id(), + resume_after); +} + +template +[[nodiscard]] Result replay_step_result( + const operation& existing, const operation_identifier& identifier, + const Serializer& serializer, execution_state& state) { + if (!existing.step || !existing.step->result) { + if constexpr (std::same_as) { + return {}; + } else { + throw invalid_state_error( + "Succeeded step has no result payload: " + + identifier.require_operation_id()); + } + } + return serializer.deserialize( + *existing.step->result, + serdes_context{ + .operation_id = identifier.require_operation_id(), + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); +} + +} // namespace detail + +template + requires( + std::invocable || + std::invocable) +[[nodiscard]] auto step( + Function&& function, Serializer serializer, step_config config = {}) + -> std::remove_cvref_t> { + using raw_result = detail::step_callable_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, + std::remove_cvref_t>; + static_assert( + serializer_for, + "The supplied step serializer does not satisfy serializer_for"); + + auto& context = current_context(); + const auto identifier = context.reserve_operation( + operation_subtype::step, operation_type::step, + config.name ? std::optional{*config.name} : std::nullopt); + const auto& id = identifier.require_operation_id(); + auto& state = context.state(); + const auto existing = state.find_operation(id); + + if (existing) { + detail::validate_replay_identity(*existing, identifier); + if (!existing->status.is_known()) { + throw invalid_state_error( + "Durable step has an unknown future status: " + + std::string{existing->status.wire_value()}); + } + if (existing->status == operation_status::succeeded) { + context.before_operation(id, false); + if constexpr (std::is_void_v) { + (void)detail::replay_step_result( + *existing, identifier, serializer, state); + return; + } else { + return detail::replay_step_result( + *existing, identifier, serializer, state); + } + } + if (existing->status == operation_status::failed || + existing->status == operation_status::cancelled || + existing->status == operation_status::timed_out || + existing->status == operation_status::stopped) { + const auto* error = + existing->step && existing->step->error ? &*existing->step->error : nullptr; + throw callable_error( + error && error->message ? *error->message : "Durable step failed", + error && error->type ? *error->type : std::string{}); + } + if (existing->status == operation_status::pending) { + context.before_operation(id, false); + detail::suspend_pending_step(identifier, *existing); + } + if (existing->status == operation_status::started && + config.semantics == step_semantics::at_most_once_per_retry) { + auto interrupted = error_object{ + .message = "At-most-once durable step was interrupted", + .type = "StepInterruptedError", + .data = std::nullopt, + .stack_trace = {}, + }; + const auto previous_attempts = + existing->step ? existing->step->attempt : 0U; + const step_interrupted_error interruption{ + "At-most-once durable step was interrupted", id}; + const auto delay = + config.retry_decider + ? config.retry_decider( + interruption, previous_attempts + 1U) + : config.retry.delay_for(previous_attempts + 1U); + if (delay) { + state.checkpoint(operation_update::step_retry( + identifier, interrupted, + static_cast(delay->count()))); + throw execution_suspended( + "Retry scheduled for interrupted durable step " + id, delay); + } + state.checkpoint(operation_update::step_fail(identifier, interrupted)); + throw callable_error( + "At-most-once durable step was interrupted", "StepInterruptedError"); + } + } + + const bool must_start = + existing == nullptr || existing->status == operation_status::ready; + context.before_operation(id, true); + if (must_start) { + state.checkpoint(operation_update::step_start(identifier)); + } + + const std::uint32_t previous_attempts = + existing && existing->step ? existing->step->attempt : 0U; + const std::uint32_t current_attempt = previous_attempts + 1U; + const bool attempt_is_replay = static_cast(existing); + const auto attempt_started = std::chrono::system_clock::now(); + bool attempt_notified = false; + state.notify_attempt_start( + id, current_attempt, attempt_started, attempt_is_replay); + try { + result_type result; + { + scoped_non_durable_region user_code_scope; + if constexpr (std::is_void_v) { + detail::invoke_step(function, current_attempt); + result = {}; + } else { + result = detail::invoke_step(function, current_attempt); + } + } + state.notify_attempt_end( + id, current_attempt, attempt_started, + std::chrono::system_clock::now(), true, nullptr, + attempt_is_replay); + attempt_notified = true; + + const auto payload = serializer.serialize( + result, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + state.checkpoint(operation_update::step_succeed(identifier, payload)); + if constexpr (std::is_void_v) { + return; + } else { + return serializer.deserialize( + payload, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + } + } catch (const execution_suspended&) { + throw; + } catch (const durable_error& error) { + if (!attempt_notified) { + const auto serialized_error = detail::exception_to_error(error); + state.notify_attempt_end( + id, current_attempt, attempt_started, + std::chrono::system_clock::now(), false, &serialized_error, + attempt_is_replay); + } + throw; + } catch (const std::exception& error) { + auto serialized_error = detail::exception_to_error(error); + state.notify_attempt_end( + id, current_attempt, attempt_started, + std::chrono::system_clock::now(), false, &serialized_error, + attempt_is_replay); + const auto attempts_made = previous_attempts + 1U; + const auto delay = + config.retry_decider + ? config.retry_decider(error, attempts_made) + : config.retry.delay_for(attempts_made); + if (delay) { + state.checkpoint(operation_update::step_retry( + identifier, serialized_error, + static_cast(delay->count()))); + throw execution_suspended( + "Retry scheduled for durable step " + id, delay); + } + state.checkpoint( + operation_update::step_fail(identifier, serialized_error)); + throw callable_error(error.what(), serialized_error.type.value_or("")); + } catch (...) { + auto serialized_error = error_object{ + .message = "Unknown non-standard exception", + .type = "unknown", + .data = std::nullopt, + .stack_trace = {}, + }; + state.notify_attempt_end( + id, current_attempt, attempt_started, + std::chrono::system_clock::now(), false, &serialized_error, + attempt_is_replay); + state.checkpoint( + operation_update::step_fail(identifier, serialized_error)); + throw callable_error("Unknown non-standard exception", "unknown"); + } +} + +template + requires( + std::invocable || + std::invocable) +[[nodiscard]] auto step(Function&& function, step_config config = {}) + -> std::remove_cvref_t> { + using raw_result = detail::step_callable_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, + std::remove_cvref_t>; + return step( + std::forward(function), default_serdes{}, + std::move(config)); +} + +inline void wait( + std::chrono::seconds duration, std::optional name = std::nullopt) { + if (duration < std::chrono::seconds{1}) { + throw durable_error( + error_code::invalid_argument, "wait duration must be at least one second"); + } + + auto& context = current_context(); + const auto identifier = context.reserve_operation( + operation_subtype::wait, operation_type::wait, + name ? std::optional{*name} : std::nullopt); + const auto& id = identifier.require_operation_id(); + auto& state = context.state(); + const auto existing = state.find_operation(id); + + if (existing) { + detail::validate_replay_identity(*existing, identifier); + if (!existing->status.is_known()) { + throw invalid_state_error( + "Durable wait has an unknown future status: " + + std::string{existing->status.wire_value()}); + } + context.before_operation(id, false); + if (existing->status == operation_status::succeeded) { + return; + } + if (existing->status == operation_status::failed || + existing->status == operation_status::cancelled || + existing->status == operation_status::timed_out || + existing->status == operation_status::stopped) { + throw callable_error("Durable wait terminated before completion"); + } + throw execution_suspended("Durable wait is pending: " + id, duration); + } + + context.before_operation(id, false); + state.checkpoint(operation_update::wait_start( + identifier, static_cast(duration.count()))); + throw execution_suspended("Durable wait started: " + id, duration); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/parallel.hpp b/include/aws/durable_execution/parallel.hpp new file mode 100644 index 0000000..81a0ee5 --- /dev/null +++ b/include/aws/durable_execution/parallel.hpp @@ -0,0 +1,1000 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/child_context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +enum class completion_reason { + all_completed, + min_successful_reached, + failure_tolerance_exceeded, + custom_completion_succeeded, + custom_completion_failed, +}; + +enum class batch_item_status { succeeded, failed, cancelled, started }; +enum class nesting_type { nested, flat }; + +[[nodiscard]] constexpr std::string_view to_string( + completion_reason value) noexcept { + switch (value) { + case completion_reason::all_completed: return "ALL_COMPLETED"; + case completion_reason::min_successful_reached: + return "MIN_SUCCESSFUL_REACHED"; + case completion_reason::failure_tolerance_exceeded: + return "FAILURE_TOLERANCE_EXCEEDED"; + case completion_reason::custom_completion_succeeded: + return "CUSTOM_COMPLETION_SUCCEEDED"; + case completion_reason::custom_completion_failed: + return "CUSTOM_COMPLETION_FAILED"; + } + return {}; +} + +[[nodiscard]] constexpr std::string_view to_string( + batch_item_status value) noexcept { + switch (value) { + case batch_item_status::succeeded: return "SUCCEEDED"; + case batch_item_status::failed: return "FAILED"; + case batch_item_status::cancelled: return "CANCELLED"; + case batch_item_status::started: return "STARTED"; + } + return {}; +} + +[[nodiscard]] constexpr bool is_success( + completion_reason value) noexcept { + return value == completion_reason::all_completed || + value == completion_reason::min_successful_reached || + value == completion_reason::custom_completion_succeeded; +} + +struct completion_status { + std::size_t success_count{0}; + std::size_t failure_count{0}; + std::size_t total_count{0}; + + [[nodiscard]] std::size_t completed_count() const noexcept { + return success_count + failure_count; + } + [[nodiscard]] bool all_completed() const noexcept { + return completed_count() == total_count; + } +}; + +struct completion_decision { + bool should_complete{false}; + std::optional reason; + + [[nodiscard]] static completion_decision complete( + completion_reason value) { + return completion_decision{ + .should_complete = true, + .reason = value, + }; + } + + [[nodiscard]] static completion_decision continue_execution() { + return {}; + } +}; + +struct completion_config { + std::optional min_successful; + std::optional tolerated_failure_count; + std::optional tolerated_failure_percentage; + std::function + should_complete; + + [[nodiscard]] static completion_config thresholds( + std::optional minimum_successful = std::nullopt, + std::optional tolerated_failures = std::nullopt, + std::optional tolerated_failure_percentage = + std::nullopt) { + if (tolerated_failure_percentage && + (*tolerated_failure_percentage < 0.0 || + *tolerated_failure_percentage > 100.0)) { + throw durable_error( + error_code::invalid_argument, + "tolerated failure percentage must be between 0 and 100"); + } + return completion_config{ + .min_successful = minimum_successful, + .tolerated_failure_count = tolerated_failures, + .tolerated_failure_percentage = + tolerated_failure_percentage, + .should_complete = {}, + }; + } + + [[nodiscard]] static completion_config first_successful() { + return thresholds(1U, std::nullopt); + } + + [[nodiscard]] static completion_config all_completed() { + return thresholds(); + } + + [[nodiscard]] static completion_config all_successful() { + return thresholds(std::nullopt, 0U); + } + + [[nodiscard]] static completion_config custom( + std::function callback) { + if (!callback) { + throw durable_error( + error_code::invalid_argument, + "custom completion callback must not be empty"); + } + return completion_config{ + .min_successful = std::nullopt, + .tolerated_failure_count = std::nullopt, + .tolerated_failure_percentage = std::nullopt, + .should_complete = std::move(callback), + }; + } + + [[nodiscard]] completion_decision decide( + const completion_status& status) const { + if (status.completed_count() > status.total_count) { + throw durable_error( + error_code::invalid_state, + "completed parallel count exceeds total count"); + } + if (should_complete) { + const auto decision = should_complete(status); + if (decision.should_complete != decision.reason.has_value()) { + throw durable_error( + error_code::invalid_state, + "custom completion decision has inconsistent reason state"); + } + return decision; + } + if (min_successful && + status.success_count >= *min_successful) { + return completion_decision::complete( + completion_reason::min_successful_reached); + } + if (tolerated_failure_count && + status.failure_count > *tolerated_failure_count) { + return completion_decision::complete( + completion_reason::failure_tolerance_exceeded); + } + if (tolerated_failure_percentage && status.total_count != 0U) { + const double percentage = + static_cast(status.failure_count) * 100.0 / + static_cast(status.total_count); + if (percentage > *tolerated_failure_percentage) { + return completion_decision::complete( + completion_reason::failure_tolerance_exceeded); + } + } + if (!min_successful && + !tolerated_failure_count && + !tolerated_failure_percentage && + status.failure_count > 0U) { + return completion_decision::complete( + completion_reason::failure_tolerance_exceeded); + } + if (status.all_completed()) { + return completion_decision::complete( + completion_reason::all_completed); + } + return completion_decision::continue_execution(); + } +}; + +template +struct batch_item { + std::size_t index{0}; + batch_item_status status{batch_item_status::started}; + std::optional result; + std::optional error; +}; + +template +struct batch_result { + std::vector> all; + completion_reason reason{completion_reason::all_completed}; + + [[nodiscard]] std::size_t success_count() const noexcept { + return static_cast(std::ranges::count_if( + all, [](const auto& item) { + return item.status == batch_item_status::succeeded; + })); + } + + [[nodiscard]] std::size_t failure_count() const noexcept { + return static_cast(std::ranges::count_if( + all, [](const auto& item) { + return item.status == batch_item_status::failed; + })); + } + + [[nodiscard]] std::size_t cancelled_count() const noexcept { + return static_cast(std::ranges::count_if( + all, [](const auto& item) { + return item.status == batch_item_status::cancelled; + })); + } + + [[nodiscard]] std::size_t total_count() const noexcept { + return all.size(); + } + + [[nodiscard]] std::vector results() const + requires std::copy_constructible + { + std::vector values; + values.reserve(success_count()); + for (const auto& item : all) { + if (item.status == batch_item_status::succeeded && item.result) { + values.push_back(*item.result); + } + } + return values; + } + + [[nodiscard]] bool has_failure() const noexcept { + return failure_count() != 0U; + } + + [[nodiscard]] batch_item_status status() const noexcept { + return has_failure() ? batch_item_status::failed + : batch_item_status::succeeded; + } + + [[nodiscard]] std::vector< + std::reference_wrapper>> + succeeded() const { + std::vector>> values; + for (const auto& item : all) { + if (item.status == batch_item_status::succeeded) { + values.emplace_back(item); + } + } + return values; + } + + [[nodiscard]] std::vector< + std::reference_wrapper>> + failed() const { + std::vector>> values; + for (const auto& item : all) { + if (item.status == batch_item_status::failed) { + values.emplace_back(item); + } + } + return values; + } + + [[nodiscard]] std::vector errors() const { + std::vector values; + for (const auto& item : all) { + if (item.status == batch_item_status::failed && item.error) { + values.push_back(*item.error); + } + } + return values; + } + + void throw_if_error() const { + for (const auto& item : all) { + if (item.status == batch_item_status::failed && item.error) { + throw callable_error( + item.error->message.value_or("Parallel branch failed"), + item.error->type.value_or("")); + } + } + } +}; + +struct parallel_config { + std::optional name; + std::optional max_concurrency; + completion_config completion{completion_config::all_successful()}; + nesting_type nesting{nesting_type::nested}; + std::function branch_namer; +}; + +struct map_config { + std::optional name; + std::optional max_concurrency; + completion_config completion{completion_config::all_completed()}; + nesting_type nesting{nesting_type::nested}; + std::function item_namer; +}; + +namespace detail { + +class batch_payload_reader { + public: + explicit batch_payload_reader(std::string_view input) : remaining_(input) {} + + [[nodiscard]] std::string_view line() { + const auto newline = remaining_.find('\n'); + if (newline == std::string_view::npos) { + throw serialization_error("Truncated batch result payload"); + } + const auto value = remaining_.substr(0, newline); + remaining_.remove_prefix(newline + 1U); + return value; + } + + [[nodiscard]] std::size_t size_value() { + const auto value = line(); + std::size_t result = 0; + const auto [end, error] = std::from_chars( + value.data(), value.data() + value.size(), result); + if (error != std::errc{} || end != value.data() + value.size()) { + throw serialization_error("Invalid batch result size field"); + } + return result; + } + + [[nodiscard]] bool boolean() { + const auto value = line(); + if (value == "0") return false; + if (value == "1") return true; + throw serialization_error("Invalid batch result boolean field"); + } + + [[nodiscard]] std::string_view blob() { + const auto size = size_value(); + if (remaining_.size() < size + 1U || remaining_[size] != '\n') { + throw serialization_error("Truncated batch result blob"); + } + const auto value = remaining_.substr(0, size); + remaining_.remove_prefix(size + 1U); + return value; + } + + [[nodiscard]] bool empty() const noexcept { return remaining_.empty(); } + + private: + std::string_view remaining_; +}; + +inline void append_line(std::string& output, std::string_view value) { + output.append(value); + output.push_back('\n'); +} + +inline void append_size(std::string& output, std::size_t value) { + char buffer[32]; + const auto [end, error] = + std::to_chars(std::begin(buffer), std::end(buffer), value); + if (error != std::errc{}) { + throw serialization_error("Failed to encode batch result size"); + } + output.append(buffer, end); + output.push_back('\n'); +} + +inline void append_bool(std::string& output, bool value) { + output.append(value ? "1\n" : "0\n"); +} + +inline void append_blob(std::string& output, std::string_view value) { + append_size(output, value.size()); + output.append(value); + output.push_back('\n'); +} + +inline void append_optional_string( + std::string& output, const std::optional& value) { + append_bool(output, value.has_value()); + if (value) { + append_blob(output, *value); + } +} + +[[nodiscard]] inline std::optional read_optional_string( + batch_payload_reader& reader) { + if (!reader.boolean()) { + return std::nullopt; + } + return std::string{reader.blob()}; +} + +[[nodiscard]] inline completion_reason completion_reason_from_wire( + std::string_view value) { + if (value == "ALL_COMPLETED") return completion_reason::all_completed; + if (value == "MIN_SUCCESSFUL_REACHED") { + return completion_reason::min_successful_reached; + } + if (value == "FAILURE_TOLERANCE_EXCEEDED") { + return completion_reason::failure_tolerance_exceeded; + } + if (value == "CUSTOM_COMPLETION_SUCCEEDED") { + return completion_reason::custom_completion_succeeded; + } + if (value == "CUSTOM_COMPLETION_FAILED") { + return completion_reason::custom_completion_failed; + } + throw serialization_error("Unknown batch completion reason"); +} + +[[nodiscard]] inline batch_item_status batch_status_from_wire( + std::string_view value) { + if (value == "SUCCEEDED") return batch_item_status::succeeded; + if (value == "FAILED") return batch_item_status::failed; + if (value == "CANCELLED") return batch_item_status::cancelled; + if (value == "STARTED") return batch_item_status::started; + throw serialization_error("Unknown batch item status"); +} + +template + requires serializer_for +class batch_result_serdes { + public: + batch_result_serdes() = default; + explicit batch_result_serdes(ItemSerializer serializer) + : item_serializer_(std::move(serializer)) {} + + [[nodiscard]] std::string serialize( + const batch_result& value, + const serdes_context& context) const { + std::string output; + output.reserve(128U + value.all.size() * 48U); + append_line(output, "DEXBR1"); + append_line(output, to_string(value.reason)); + append_size(output, value.all.size()); + for (const auto& item : value.all) { + append_size(output, item.index); + append_line(output, to_string(item.status)); + append_bool(output, item.result.has_value()); + if (item.result) { + append_blob( + output, item_serializer_.serialize(*item.result, context)); + } + append_bool(output, item.error.has_value()); + if (item.error) { + append_optional_string(output, item.error->message); + append_optional_string(output, item.error->type); + append_optional_string(output, item.error->data); + append_size(output, item.error->stack_trace.size()); + for (const auto& frame : item.error->stack_trace) { + append_blob(output, frame); + } + } + } + return output; + } + + [[nodiscard]] batch_result deserialize( + std::string_view data, const serdes_context& context) const { + batch_payload_reader reader{data}; + if (reader.line() != "DEXBR1") { + throw serialization_error("Unsupported batch result payload version"); + } + batch_result result; + result.reason = completion_reason_from_wire(reader.line()); + const auto count = reader.size_value(); + result.all.reserve(count); + for (std::size_t item_index = 0; item_index < count; ++item_index) { + batch_item item; + item.index = reader.size_value(); + item.status = batch_status_from_wire(reader.line()); + if (reader.boolean()) { + item.result = + item_serializer_.deserialize(reader.blob(), context); + } + if (reader.boolean()) { + error_object error; + error.message = read_optional_string(reader); + error.type = read_optional_string(reader); + error.data = read_optional_string(reader); + const auto stack_count = reader.size_value(); + error.stack_trace.reserve(stack_count); + for (std::size_t frame = 0; frame < stack_count; ++frame) { + error.stack_trace.emplace_back(reader.blob()); + } + item.error = std::move(error); + } + result.all.push_back(std::move(item)); + } + if (!reader.empty()) { + throw serialization_error("Trailing batch result payload data"); + } + return result; + } + + private: + ItemSerializer item_serializer_; +}; + +[[nodiscard]] inline error_object branch_error( + const std::exception& exception) { + auto result = exception_to_error(exception); + if (const auto* callable = + dynamic_cast(&exception); + callable && !callable->type().empty()) { + result.type = callable->type(); + } + return result; +} + +[[nodiscard]] inline std::size_t worker_count( + std::size_t total, std::size_t configured) noexcept { + if (total == 0U) { + return 0U; + } + if (configured != 0U) { + return std::min(total, configured); + } + const auto hardware = std::thread::hardware_concurrency(); + return std::min( + total, hardware == 0U ? std::size_t{4} + : static_cast(hardware)); +} + +[[nodiscard]] inline std::size_t validated_concurrency( + const std::optional& configured) { + if (configured && *configured == 0U) { + throw durable_error( + error_code::invalid_argument, + "max_concurrency must be a positive integer"); + } + return configured.value_or(0U); +} + +template +[[nodiscard]] batch_result execute_concurrent( + durable_context& batch_context, std::size_t count, Execute& execute, + const std::function& branch_namer, + std::string_view branch_sub_type, std::size_t max_concurrency, + const completion_config& completion, nesting_type nesting, + ItemSerializer item_serializer) { + static_assert( + serializer_for, + "The supplied branch serializer does not satisfy serializer_for"); + + batch_result result; + result.all.resize(count); + for (std::size_t index = 0; index < count; ++index) { + result.all[index].index = index; + } + if (count == 0U) { + result.reason = completion_reason::all_completed; + return result; + } + + const std::uint64_t base_operation_index = + batch_context.current_operation_index(); + std::vector branch_names; + branch_names.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + branch_names.push_back(branch_namer(index)); + (void)batch_context.reserve_operation( + branch_sub_type, operation_type::context, branch_names.back()); + } + + std::atomic next_index{0}; + std::atomic stop_requested{false}; + std::mutex result_mutex; + std::vector started(count, false); + completion_status status{.total_count = count}; + std::optional final_decision; + std::exception_ptr suspension; + std::exception_ptr fatal_error; + + auto record_decision = [&] { + if (!final_decision) { + try { + const auto decision = completion.decide(status); + if (decision.should_complete) { + final_decision = decision; + stop_requested.store(true, std::memory_order_release); + } + } catch (...) { + if (!fatal_error) { + fatal_error = std::current_exception(); + } + stop_requested.store(true, std::memory_order_release); + } + } + }; + + auto worker = [&] { + while (!stop_requested.load(std::memory_order_acquire)) { + const std::size_t index = + next_index.fetch_add(1U, std::memory_order_relaxed); + if (index >= count) { + return; + } + if (stop_requested.load(std::memory_order_acquire)) { + return; + } + { + std::lock_guard lock{result_mutex}; + started[index] = true; + } + + auto branch_parent = batch_context.fork_at( + base_operation_index + static_cast(index)); + scoped_context parent_binding{branch_parent}; + try { + auto branch_result = run_in_child_context( + [&] { return std::invoke(execute, index); }, + item_serializer, + child_context_config{ + .name = branch_names[index], + .is_virtual = nesting == nesting_type::flat, + .sub_type = std::string{branch_sub_type}, + }); + { + std::lock_guard lock{result_mutex}; + auto& item = result.all[index]; + item.status = batch_item_status::succeeded; + item.result = std::move(branch_result); + ++status.success_count; + record_decision(); + } + } catch (const execution_suspended&) { + std::lock_guard lock{result_mutex}; + if (!suspension) { + suspension = std::current_exception(); + } + return; + } catch (const checkpoint_error&) { + std::lock_guard lock{result_mutex}; + if (!fatal_error) { + fatal_error = std::current_exception(); + } + stop_requested.store(true, std::memory_order_release); + return; + } catch (const state_fetch_error&) { + std::lock_guard lock{result_mutex}; + if (!fatal_error) { + fatal_error = std::current_exception(); + } + stop_requested.store(true, std::memory_order_release); + return; + } catch (const std::exception& exception) { + std::lock_guard lock{result_mutex}; + auto& item = result.all[index]; + item.status = batch_item_status::failed; + item.error = branch_error(exception); + ++status.failure_count; + record_decision(); + } catch (...) { + std::lock_guard lock{result_mutex}; + auto& item = result.all[index]; + item.status = batch_item_status::failed; + item.error = error_object{ + .message = "Unknown non-standard exception", + .type = "unknown", + .data = std::nullopt, + .stack_trace = {}, + }; + ++status.failure_count; + record_decision(); + } + } + }; + + auto checkpoint_batching = + batch_context.state().enable_checkpoint_batching(); + std::vector workers; + workers.reserve(worker_count(count, max_concurrency)); + for (std::size_t index = 0; + index < worker_count(count, max_concurrency); ++index) { + workers.emplace_back(worker); + } + workers.clear(); + + if (fatal_error) { + std::rethrow_exception(fatal_error); + } + if (!final_decision) { + final_decision = completion.decide(status); + } + if (final_decision->should_complete) { + std::vector> settled_items; + settled_items.reserve(result.all.size()); + for (std::size_t index = 0; index < result.all.size(); ++index) { + if (!started[index]) { + continue; + } + auto item = std::move(result.all[index]); + if (item.status == batch_item_status::started) { + item.status = batch_item_status::cancelled; + } + settled_items.push_back(std::move(item)); + } + result.all = std::move(settled_items); + result.reason = *final_decision->reason; + return result; + } + if (suspension) { + std::rethrow_exception(suspension); + } + + throw invalid_state_error( + "Parallel execution ended without completion or suspension"); +} + +template +[[nodiscard]] std::string batch_summary( + const batch_result& result, std::string_view type) { + return std::string{"{\"type\":\""} + std::string{type} + + "\",\"totalCount\":" + std::to_string(result.all.size()) + + ",\"successCount\":" + std::to_string(result.success_count()) + + ",\"failureCount\":" + std::to_string(result.failure_count()) + + ",\"completionReason\":\"" + + std::string{to_string(result.reason)} + "\"}"; +} + +template +Result invoke_tuple_branch( + Tuple& branches, std::size_t index, + std::index_sequence) { + std::optional result; + auto invoke_at = [&]() { + if (index != BranchIndex) { + return false; + } + using branch_type = std::tuple_element_t; + if constexpr ( + std::is_void_v>) { + std::invoke(std::get(branches)); + result.emplace(); + } else { + result.emplace(std::invoke(std::get(branches))); + } + return true; + }; + const bool invoked = + (invoke_at.template operator()() || ...); + if (!invoked || !result) { + throw invalid_state_error("Parallel branch index is out of range"); + } + return std::move(*result); +} + +template < + typename Function, typename Item, + bool WithIndex = + std::invocable> +struct map_callable_result; + +template +struct map_callable_result { + using type = + std::invoke_result_t; +}; + +template +struct map_callable_result { + using type = std::invoke_result_t; +}; + +template +using map_callable_result_t = + typename map_callable_result::type; + +template +decltype(auto) invoke_map_item( + Function& function, const Item& item, std::size_t index) { + if constexpr ( + std::invocable) { + return std::invoke(function, item, index); + } else { + return std::invoke(function, item); + } +} + +} // namespace detail + +template < + std::ranges::random_access_range Branches, + typename Branch = std::ranges::range_reference_t, + typename RawResult = std::invoke_result_t, + typename Result = std::conditional_t< + std::is_void_v, std::monostate, + std::remove_cvref_t>, + typename ItemSerializer = default_serdes> + requires std::ranges::sized_range && + std::invocable && + serializer_for && + std::copy_constructible +[[nodiscard]] batch_result parallel( + Branches&& branches, parallel_config config = {}, + ItemSerializer item_serializer = {}) { + const auto max_concurrency = + detail::validated_concurrency(config.max_concurrency); + auto execute = [&](std::size_t index) -> Result { + auto&& branch = *( + std::ranges::begin(branches) + + static_cast>(index)); + if constexpr (std::is_void_v) { + std::invoke(branch); + return {}; + } else { + return std::invoke(branch); + } + }; + detail::batch_result_serdes result_serializer{ + item_serializer}; + const auto branch_namer = + config.branch_namer + ? config.branch_namer + : std::function{ + [](std::size_t index) { + return "parallel-branch-" + std::to_string(index); + }}; + return run_in_child_context( + [&](durable_context& context) { + return detail::execute_concurrent( + context, std::ranges::size(branches), execute, branch_namer, + operation_subtype::parallel_branch, max_concurrency, + config.completion, config.nesting, + item_serializer); + }, + result_serializer, + [](const batch_result& result) { + return detail::batch_summary(result, "ParallelResult"); + }, + child_context_config{ + .name = config.name, + .is_virtual = false, + .sub_type = std::string{operation_subtype::parallel}, + }); +} + +template < + typename First, typename... Rest, + typename RawResult = std::invoke_result_t, + typename Result = std::conditional_t< + std::is_void_v, std::monostate, + std::remove_cvref_t>, + typename ItemSerializer = default_serdes> + requires( + std::invocable && (std::invocable && ...) && + ((std::is_void_v && + (std::is_void_v> && ...)) || + (!std::is_void_v && + (std::same_as< + Result, + std::remove_cvref_t>> && + ...))) && + serializer_for && + std::copy_constructible) +[[nodiscard]] batch_result parallel( + std::tuple branches, + parallel_config config = {}, ItemSerializer item_serializer = {}) { + const auto max_concurrency = + detail::validated_concurrency(config.max_concurrency); + auto execute = [&](std::size_t index) -> Result { + return detail::invoke_tuple_branch( + branches, index, + std::index_sequence_for{}); + }; + detail::batch_result_serdes result_serializer{ + item_serializer}; + const auto branch_namer = + config.branch_namer + ? config.branch_namer + : std::function{ + [](std::size_t index) { + return "parallel-branch-" + std::to_string(index); + }}; + return run_in_child_context( + [&](durable_context& context) { + return detail::execute_concurrent( + context, sizeof...(Rest) + 1U, execute, branch_namer, + operation_subtype::parallel_branch, max_concurrency, + config.completion, config.nesting, + item_serializer); + }, + result_serializer, + [](const batch_result& result) { + return detail::batch_summary(result, "ParallelResult"); + }, + child_context_config{ + .name = config.name, + .is_virtual = false, + .sub_type = std::string{operation_subtype::parallel}, + }); +} + +template < + typename Function, std::ranges::input_range Items, + typename Item = std::ranges::range_value_t, + typename RawResult = + detail::map_callable_result_t, + typename Result = std::conditional_t< + std::is_void_v, std::monostate, + std::remove_cvref_t>, + typename ItemSerializer = default_serdes, + typename OperationSerializer = + detail::batch_result_serdes> + requires( + std::invocable || + std::invocable) && + serializer_for && + serializer_for< + OperationSerializer, batch_result> && + std::copy_constructible +[[nodiscard]] batch_result map( + Function&& function, Items&& input_items, map_config config = {}, + ItemSerializer item_serializer = {}, + OperationSerializer operation_serializer = {}) { + const auto max_concurrency = + detail::validated_concurrency(config.max_concurrency); + std::vector items; + if constexpr (std::ranges::sized_range) { + items.reserve(std::ranges::size(input_items)); + } + for (auto&& item : input_items) { + items.emplace_back(item); + } + auto execute = [&](std::size_t index) -> Result { + if constexpr (std::is_void_v) { + detail::invoke_map_item( + function, std::as_const(items[index]), index); + return {}; + } else { + return detail::invoke_map_item( + function, std::as_const(items[index]), index); + } + }; + const auto item_namer = + config.item_namer + ? config.item_namer + : std::function{ + [](std::size_t index) { + return "map-item-" + std::to_string(index); + }}; + if constexpr (std::same_as< + OperationSerializer, + detail::batch_result_serdes>) { + operation_serializer = OperationSerializer{item_serializer}; + } + return run_in_child_context( + [&](durable_context& context) { + return detail::execute_concurrent( + context, items.size(), execute, item_namer, + operation_subtype::map_iteration, max_concurrency, + config.completion, config.nesting, item_serializer); + }, + std::move(operation_serializer), + [](const batch_result& result) { + return detail::batch_summary(result, "MapResult"); + }, + child_context_config{ + .name = config.name, + .is_virtual = false, + .sub_type = std::string{operation_subtype::map}, + }); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/plugin.hpp b/include/aws/durable_execution/plugin.hpp new file mode 100644 index 0000000..3152cc3 --- /dev/null +++ b/include/aws/durable_execution/plugin.hpp @@ -0,0 +1,155 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/model.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +inline constexpr std::uint32_t instrumentation_plugin_api_version = 1; + +struct operation_info { + std::string_view durable_execution_arn; + std::string_view id; + std::optional name; + std::string_view type; + std::optional sub_type; + std::optional parent_id; + std::optional status; + std::optional start_timestamp; + std::optional end_timestamp; + std::optional result; + const error_object* error{nullptr}; + std::optional attempt; + bool is_replay{false}; + bool is_replaying_children{false}; +}; + +struct attempt_info { + operation_info operation; + std::uint32_t attempt{1}; + timestamp start_timestamp{}; + std::optional end_timestamp; + std::optional succeeded; + const error_object* error{nullptr}; +}; + +struct invocation_info { + std::string_view request_id; + std::string_view execution_arn; + std::string_view execution_input; + std::span operations; + std::span updated_operations; + std::optional execution_start_timestamp; + bool is_first_invocation{true}; +}; + +struct invocation_end_info { + std::string_view request_id; + std::string_view execution_arn; + std::string_view execution_input; + std::span operations; + std::optional execution_start_timestamp; + invocation_status status{invocation_status::succeeded}; + std::optional execution_result; + const error_object* execution_error{nullptr}; + bool is_first_invocation{true}; +}; + +struct operation_change_info { + std::string_view execution_arn; + std::span updated_operations; + std::span operations; +}; + +class instrumentation_plugin { + public: + virtual ~instrumentation_plugin() = default; + + virtual void on_invocation_start(const invocation_info&) {} + virtual void on_invocation_end(const invocation_end_info&) {} + virtual void on_operation_start(const operation_info&) {} + virtual void on_operation_end(const operation_info&) {} + virtual void on_attempt_start(const attempt_info&) {} + virtual void on_attempt_end(const attempt_info&) {} + virtual void on_operation_change(const operation_change_info&) {} +}; + +struct run_options { + std::vector> plugins; + std::string request_id; +}; + +namespace detail { + +class plugin_manager { + public: + using operation_snapshot = std::shared_ptr; + + explicit plugin_manager(const run_options& options); + + [[nodiscard]] bool enabled() const noexcept { + return !plugins_.empty(); + } + + void invocation_start( + std::string_view request_id, std::string_view execution_arn, + std::string_view execution_input, + std::span operations, + std::span updated_operations, + std::optional execution_start_timestamp, + bool is_first_invocation) noexcept; + + void invocation_end( + std::string_view request_id, std::string_view execution_arn, + std::string_view execution_input, + std::span operations, + std::optional execution_start_timestamp, + invocation_status status, const std::optional& result, + const std::optional& error, + bool is_first_invocation) noexcept; + + void operation_start( + std::string_view execution_arn, const operation_snapshot& value, + bool is_replay, bool is_replaying_children = false) noexcept; + + void operation_end( + std::string_view execution_arn, const operation_snapshot& value, + bool is_replay) noexcept; + + void attempt_start( + std::string_view execution_arn, const operation_snapshot& value, + std::uint32_t attempt, timestamp started, bool is_replay, + bool is_replaying_children = false) noexcept; + + void attempt_end( + std::string_view execution_arn, const operation_snapshot& value, + std::uint32_t attempt, timestamp started, timestamp ended, + bool succeeded, const error_object* error, bool is_replay, + bool is_replaying_children = false) noexcept; + + void operation_change( + std::string_view execution_arn, + std::span updated_operations, + std::span operations, + bool is_replay) noexcept; + + [[nodiscard]] static operation_info make_operation_info( + std::string_view execution_arn, const operation_snapshot& value, + bool is_replay, bool is_replaying_children = false) noexcept; + + private: + std::vector> plugins_; +}; + +} // namespace detail + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/recurse.hpp b/include/aws/durable_execution/recurse.hpp new file mode 100644 index 0000000..e30cac9 --- /dev/null +++ b/include/aws/durable_execution/recurse.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/invoke.hpp" +#include "aws/durable_execution/serdes.hpp" +#include "aws/durable_execution/wire.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +inline constexpr std::string_view recursive_level_field = + "__recursive_level"; + +struct recurse_config { + std::optional name; + std::optional function_name; + bool with_recursive_level{false}; + std::optional tenant_id; +}; + +namespace detail { + +[[nodiscard]] inline bool is_qualified_function_arn( + std::string_view function_name) noexcept { + return function_name.starts_with("arn:") && + static_cast( + std::ranges::count(function_name, ':')) >= 7U; +} + +[[nodiscard]] inline bool is_qualified_function_name( + std::string_view function_name) noexcept { + return !function_name.starts_with("arn:") && + function_name.find(':') != std::string_view::npos; +} + +[[nodiscard]] inline std::string append_qualifier( + std::string function_name, + const std::optional& qualifier) { + if (!qualifier || qualifier->empty() || + is_qualified_function_arn(function_name) || + is_qualified_function_name(function_name)) { + return function_name; + } + function_name.push_back(':'); + function_name.append(*qualifier); + return function_name; +} + +[[nodiscard]] inline std::string recursive_function_name( + const execution_state& state, + const std::optional& explicit_name) { + if (explicit_name && !explicit_name->empty()) { + return *explicit_name; + } + const auto& metadata = state.invocation_metadata(); + if (metadata.invoked_function_arn && + !metadata.invoked_function_arn->empty()) { + return append_qualifier( + *metadata.invoked_function_arn, metadata.function_version); + } + if (metadata.function_name && !metadata.function_name->empty()) { + return append_qualifier( + *metadata.function_name, metadata.function_version); + } + throw invalid_state_error( + "recurse requires invocation metadata or an explicit function name"); +} + +template + requires serializer_for +class recursive_payload_serdes { + public: + recursive_payload_serdes( + Serializer serializer, const execution_state& state, + bool with_recursive_level) + : serializer_(std::move(serializer)), + state_(&state), + with_recursive_level_(with_recursive_level) {} + + [[nodiscard]] std::string serialize( + const Payload& payload, const serdes_context& context) const { + std::string serialized = serializer_.serialize(payload, context); + if (with_recursive_level_) { + auto updated = set_json_object_integer_field( + serialized, recursive_level_field, + static_cast(state_->recursive_level()) + 1); + if (!updated) { + throw serialization_error( + "recurse payload must serialize to a JSON object when " + "with_recursive_level is enabled: " + + updated.error().message); + } + serialized = std::move(*updated); + } + if (serialized == state_->input_payload()) { + throw durable_error( + error_code::invalid_argument, + "recurse payload must differ from the current execution input"); + } + return serialized; + } + + [[nodiscard]] Payload deserialize( + std::string_view data, const serdes_context& context) const { + return serializer_.deserialize(data, context); + } + + private: + Serializer serializer_; + const execution_state* state_; + bool with_recursive_level_; +}; + +} // namespace detail + +template < + typename Result = std::string, typename Payload, + typename PayloadSerializer = + default_serdes>, + typename ResultSerializer = default_serdes> + requires serializer_for< + PayloadSerializer, std::remove_cvref_t> && + serializer_for +[[nodiscard]] std::optional recurse( + const Payload& payload, recurse_config config = {}, + PayloadSerializer payload_serializer = {}, + ResultSerializer result_serializer = {}) { + auto& context = current_context(); + auto& state = context.state(); + const std::string function_name = + detail::recursive_function_name(state, config.function_name); + + std::optional tenant_id = std::move(config.tenant_id); + if (!tenant_id) { + tenant_id = state.invocation_metadata().tenant_id; + } + + using payload_type = std::remove_cvref_t; + return invoke( + function_name, payload, + invoke_config{ + .name = std::move(config.name), + .tenant_id = std::move(tenant_id), + }, + detail::recursive_payload_serdes{ + std::move(payload_serializer), state, + config.with_recursive_level}, + std::move(result_serializer)); +} + +template < + typename Result = std::string, + typename ResultSerializer = default_serdes> + requires serializer_for +[[nodiscard]] std::optional recurse_json( + std::string_view payload, recurse_config config = {}, + ResultSerializer result_serializer = {}) { + return recurse( + std::string{payload}, std::move(config), passthrough_serdes{}, + std::move(result_serializer)); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/replay_safe.hpp b/include/aws/durable_execution/replay_safe.hpp new file mode 100644 index 0000000..a0dc7f0 --- /dev/null +++ b/include/aws/durable_execution/replay_safe.hpp @@ -0,0 +1,274 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +class uuid_value { + public: + using storage_type = std::array; + + uuid_value() = default; + explicit uuid_value(storage_type bytes) noexcept : bytes_(bytes) {} + + [[nodiscard]] const storage_type& bytes() const noexcept { return bytes_; } + [[nodiscard]] std::string to_string() const { + constexpr char hex[] = "0123456789abcdef"; + std::string output(36, '-'); + std::size_t output_index = 0; + for (std::size_t byte_index = 0; byte_index < bytes_.size(); + ++byte_index) { + if (output_index == 8U || output_index == 13U || + output_index == 18U || output_index == 23U) { + ++output_index; + } + output[output_index++] = hex[bytes_[byte_index] >> 4U]; + output[output_index++] = hex[bytes_[byte_index] & 0x0FU]; + } + return output; + } + + [[nodiscard]] static uuid_value parse(std::string_view value) { + if (value.size() != 36U || value[8] != '-' || value[13] != '-' || + value[18] != '-' || value[23] != '-') { + throw serialization_error("Invalid UUID string"); + } + storage_type bytes{}; + std::size_t input_index = 0; + for (auto& byte : bytes) { + while (input_index < value.size() && value[input_index] == '-') { + ++input_index; + } + if (input_index + 1U >= value.size()) { + throw serialization_error("Truncated UUID string"); + } + byte = static_cast( + (hex_digit(value[input_index]) << 4U) | + hex_digit(value[input_index + 1U])); + input_index += 2U; + } + return uuid_value{bytes}; + } + + friend bool operator==(const uuid_value&, const uuid_value&) = default; + + private: + [[nodiscard]] static std::uint8_t hex_digit(char value) { + if (value >= '0' && value <= '9') { + return static_cast(value - '0'); + } + if (value >= 'a' && value <= 'f') { + return static_cast(value - 'a' + 10); + } + if (value >= 'A' && value <= 'F') { + return static_cast(value - 'A' + 10); + } + throw serialization_error("Invalid UUID hexadecimal digit"); + } + + storage_type bytes_{}; +}; + +struct uuid_serdes { + [[nodiscard]] std::string serialize( + const uuid_value& value, const serdes_context&) const { + return std::string{R"({"t":"u","v":")"} + + value.to_string() + R"("})"; + } + + [[nodiscard]] uuid_value deserialize( + std::string_view data, const serdes_context&) const { + constexpr std::string_view prefix = R"({"t":"u","v":")"; + constexpr std::string_view suffix = R"("})"; + if (!data.starts_with(prefix) || !data.ends_with(suffix) || + data.size() <= prefix.size() + suffix.size()) { + throw serialization_error("Invalid tagged UUID payload"); + } + return uuid_value::parse(data.substr( + prefix.size(), + data.size() - prefix.size() - suffix.size())); + } +}; + +struct timestamp_serdes { + [[nodiscard]] std::string serialize( + const timestamp& value, const serdes_context&) const { + const auto day = std::chrono::floor(value); + const std::chrono::year_month_day date{day}; + if (!date.ok() || static_cast(date.year()) < 0 || + static_cast(date.year()) > 9999) { + throw serialization_error( + "Replay-safe datetime is outside the supported year range"); + } + const std::chrono::hh_mm_ss time{ + std::chrono::floor(value - day)}; + + std::string output{R"({"t":"dt","v":")"}; + append_padded( + output, + static_cast(static_cast(date.year())), 4); + output.push_back('-'); + append_padded(output, static_cast(date.month()), 2); + output.push_back('-'); + append_padded(output, static_cast(date.day()), 2); + output.push_back('T'); + append_padded(output, time.hours().count(), 2); + output.push_back(':'); + append_padded(output, time.minutes().count(), 2); + output.push_back(':'); + append_padded(output, time.seconds().count(), 2); + output.push_back('.'); + append_padded( + output, + std::chrono::duration_cast( + time.subseconds()) + .count(), + 6); + output.append(R"(+00:00"})"); + return output; + } + + [[nodiscard]] timestamp deserialize( + std::string_view data, const serdes_context&) const { + constexpr std::string_view prefix = R"({"t":"dt","v":")"; + constexpr std::string_view suffix = R"("})"; + if (!data.starts_with(prefix) || !data.ends_with(suffix)) { + throw serialization_error("Invalid tagged datetime payload"); + } + const auto value = data.substr( + prefix.size(), + data.size() - prefix.size() - suffix.size()); + if (value.size() != 32U || value[4] != '-' || value[7] != '-' || + value[10] != 'T' || value[13] != ':' || value[16] != ':' || + value[19] != '.' || value.substr(26) != "+00:00") { + throw serialization_error("Invalid ISO-8601 UTC datetime payload"); + } + + const int year = parse_number(value.substr(0, 4)); + const unsigned month = + static_cast(parse_number(value.substr(5, 2))); + const unsigned day = + static_cast(parse_number(value.substr(8, 2))); + const int hour = parse_number(value.substr(11, 2)); + const int minute = parse_number(value.substr(14, 2)); + const int second = parse_number(value.substr(17, 2)); + const int micros = parse_number(value.substr(20, 6)); + const std::chrono::year_month_day date{ + std::chrono::year{year}, std::chrono::month{month}, + std::chrono::day{day}}; + if (!date.ok() || hour > 23 || minute > 59 || second > 59) { + throw serialization_error("Invalid ISO-8601 datetime component"); + } + const auto parsed = + std::chrono::sys_days{date} + std::chrono::hours{hour} + + std::chrono::minutes{minute} + std::chrono::seconds{second} + + std::chrono::microseconds{micros}; + return timestamp{std::chrono::duration_cast( + parsed.time_since_epoch())}; + } + + private: + template + static void append_padded( + std::string& output, Integer value, std::size_t width) { + char buffer[32]; + const auto [end, error] = + std::to_chars(std::begin(buffer), std::end(buffer), value); + if (error != std::errc{}) { + throw serialization_error("Failed to format datetime component"); + } + const auto length = static_cast(end - buffer); + if (length > width) { + throw serialization_error("Datetime component exceeds fixed width"); + } + output.append(width - length, '0'); + output.append(buffer, end); + } + + static int parse_number(std::string_view value) { + int result = 0; + const auto [end, error] = std::from_chars( + value.data(), value.data() + value.size(), result); + if (error != std::errc{} || end != value.data() + value.size()) { + throw serialization_error("Invalid datetime numeric component"); + } + return result; + } +}; + +namespace replay_safe { + +[[nodiscard]] inline double random( + std::optional name = std::nullopt) { + return step( + [] { + thread_local std::mt19937_64 generator{std::random_device{}()}; + return std::generate_canonical(generator); + }, + step_config{.name = name.value_or("random")}); +} + +[[nodiscard]] inline timestamp now( + std::optional name = std::nullopt) { + return step( + [] { + const auto millis = + std::chrono::time_point_cast( + std::chrono::system_clock::now()); + return timestamp{std::chrono::duration_cast( + millis.time_since_epoch())}; + }, + timestamp_serdes{}, step_config{.name = name.value_or("now")}); +} + +[[nodiscard]] inline double timestamp_seconds( + std::optional name = std::nullopt) { + return step( + [] { + return std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + }, + step_config{.name = name.value_or("timestamp")}); +} + +[[nodiscard]] inline uuid_value uuid( + std::optional name = std::nullopt) { + return step( + [] { + uuid_value::storage_type bytes{}; + std::random_device source; + for (std::size_t index = 0; index < bytes.size(); index += 4U) { + const std::uint32_t value = source(); + for (std::size_t offset = 0; offset < 4U; ++offset) { + bytes[index + offset] = static_cast( + value >> static_cast(offset * 8U)); + } + } + bytes[6] = static_cast( + (bytes[6] & 0x0FU) | 0x40U); + bytes[8] = static_cast( + (bytes[8] & 0x3FU) | 0x80U); + return uuid_value{bytes}; + }, + uuid_serdes{}, step_config{.name = name.value_or("uuid")}); +} + +} // namespace replay_safe + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/runtime.hpp b/include/aws/durable_execution/runtime.hpp new file mode 100644 index 0000000..d04101f --- /dev/null +++ b/include/aws/durable_execution/runtime.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/handler.hpp" +#include "aws/durable_execution/serdes.hpp" +#include "aws/durable_execution/service_client.hpp" +#include "aws/durable_execution/wire.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> +[[nodiscard]] std::expected run_json( + std::string_view event_json, service_client& client, + const lambda_invocation_metadata& invocation_metadata, + Function&& function, Serializer serializer, + const run_options& options) { + auto input = decode_invocation_input(event_json); + if (!input) { + return std::unexpected(std::move(input.error())); + } + return encode_invocation_output(run( + *input, client, invocation_metadata, + std::forward(function), std::move(serializer), options)); +} + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> +[[nodiscard]] std::expected run_json( + std::string_view event_json, service_client& client, + const lambda_invocation_metadata& invocation_metadata, + Function&& function, Serializer serializer) { + return run_json( + event_json, client, invocation_metadata, + std::forward(function), std::move(serializer), + run_options{}); +} + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> +[[nodiscard]] std::expected run_json( + std::string_view event_json, service_client& client, Function&& function, + Serializer serializer, const run_options& options) { + return run_json( + event_json, client, lambda_invocation_metadata{}, + std::forward(function), std::move(serializer), options); +} + +template + requires detail::durable_handler_function && + serializer_for< + Serializer, detail::handler_value_t> +[[nodiscard]] std::expected run_json( + std::string_view event_json, service_client& client, Function&& function, + Serializer serializer) { + return run_json( + event_json, client, std::forward(function), + std::move(serializer), run_options{}); +} + +template + requires detail::durable_handler_function +[[nodiscard]] std::expected run_json( + std::string_view event_json, service_client& client, + const lambda_invocation_metadata& invocation_metadata, + Function&& function) { + using raw_result = detail::handler_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, raw_result>; + return run_json( + event_json, client, invocation_metadata, + std::forward(function), + default_serdes{}); +} + +template + requires detail::durable_handler_function +[[nodiscard]] std::expected run_json( + std::string_view event_json, service_client& client, Function&& function) { + using raw_result = detail::handler_result_t; + using result_type = std::conditional_t< + std::is_void_v, std::monostate, raw_result>; + return run_json( + event_json, client, std::forward(function), + default_serdes{}); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/serdes.hpp b/include/aws/durable_execution/serdes.hpp new file mode 100644 index 0000000..8833a48 --- /dev/null +++ b/include/aws/durable_execution/serdes.hpp @@ -0,0 +1,290 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/error.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct serdes_context { + std::string_view operation_id; + std::string_view durable_execution_arn; + std::uint32_t recursive_level{0}; +}; + +namespace detail { + +[[nodiscard]] inline std::string json_quote(std::string_view value) { + std::string output; + output.reserve(value.size() + 2); + output.push_back('"'); + constexpr char hex[] = "0123456789abcdef"; + for (const unsigned char character : value) { + switch (character) { + case '"': output.append("\\\""); break; + case '\\': output.append("\\\\"); break; + case '\b': output.append("\\b"); break; + case '\f': output.append("\\f"); break; + case '\n': output.append("\\n"); break; + case '\r': output.append("\\r"); break; + case '\t': output.append("\\t"); break; + default: + if (character < 0x20U) { + output.append("\\u00"); + output.push_back(hex[character >> 4U]); + output.push_back(hex[character & 0x0FU]); + } else { + output.push_back(static_cast(character)); + } + } + } + output.push_back('"'); + return output; +} + +[[nodiscard]] inline unsigned decode_hex(char value) { + if (value >= '0' && value <= '9') { + return static_cast(value - '0'); + } + if (value >= 'a' && value <= 'f') { + return static_cast(value - 'a' + 10); + } + if (value >= 'A' && value <= 'F') { + return static_cast(value - 'A' + 10); + } + throw serialization_error("Invalid hexadecimal digit in JSON string"); +} + +inline void append_utf8(std::string& output, unsigned codepoint) { + if (codepoint <= 0x7FU) { + output.push_back(static_cast(codepoint)); + } else if (codepoint <= 0x7FFU) { + output.push_back(static_cast(0xC0U | (codepoint >> 6U))); + output.push_back(static_cast(0x80U | (codepoint & 0x3FU))); + } else { + output.push_back(static_cast(0xE0U | (codepoint >> 12U))); + output.push_back(static_cast(0x80U | ((codepoint >> 6U) & 0x3FU))); + output.push_back(static_cast(0x80U | (codepoint & 0x3FU))); + } +} + +[[nodiscard]] inline std::string json_unquote(std::string_view value) { + if (value.size() < 2 || value.front() != '"' || value.back() != '"') { + throw serialization_error("Expected a JSON string"); + } + + std::string output; + output.reserve(value.size() - 2); + for (std::size_t index = 1; index + 1 < value.size(); ++index) { + const char character = value[index]; + if (character != '\\') { + output.push_back(character); + continue; + } + if (++index + 1 >= value.size()) { + throw serialization_error("Truncated JSON escape sequence"); + } + switch (value[index]) { + case '"': output.push_back('"'); break; + case '\\': output.push_back('\\'); break; + case '/': output.push_back('/'); break; + case 'b': output.push_back('\b'); break; + case 'f': output.push_back('\f'); break; + case 'n': output.push_back('\n'); break; + case 'r': output.push_back('\r'); break; + case 't': output.push_back('\t'); break; + case 'u': { + if (index + 4 >= value.size()) { + throw serialization_error("Truncated JSON unicode escape"); + } + unsigned codepoint = 0; + for (int digit = 0; digit < 4; ++digit) { + codepoint = (codepoint << 4U) | decode_hex(value[++index]); + } + if (codepoint >= 0xD800U && codepoint <= 0xDFFFU) { + throw serialization_error( + "UTF-16 surrogate pairs are not accepted by the fast string codec"); + } + append_utf8(output, codepoint); + break; + } + default: throw serialization_error("Unknown JSON escape sequence"); + } + } + return output; +} + +} // namespace detail + +template +concept serializer_for = requires( + const Serializer& serializer, const T& value, std::string_view data, + const serdes_context& context) { + { serializer.serialize(value, context) } -> std::same_as; + { serializer.deserialize(data, context) } -> std::same_as; +}; + +template +struct default_serdes; + +struct passthrough_serdes { + [[nodiscard]] std::string serialize( + const std::string& value, const serdes_context&) const { + return value; + } + + [[nodiscard]] std::string deserialize( + std::string_view data, const serdes_context&) const { + return std::string{data}; + } +}; + +template > + requires serializer_for +class optional_serdes { + public: + optional_serdes() = default; + explicit optional_serdes(Serializer serializer) + : serializer_(std::move(serializer)) {} + + [[nodiscard]] std::string serialize( + const std::optional& value, const serdes_context& context) const { + if (!value) { + return R"({"p":false})"; + } + std::string output{R"({"p":true,"v":)"}; + output.append(detail::json_quote(serializer_.serialize(*value, context))); + output.push_back('}'); + return output; + } + + [[nodiscard]] std::optional deserialize( + std::string_view data, const serdes_context& context) const { + if (data == R"({"p":false})") { + return std::nullopt; + } + constexpr std::string_view prefix = R"({"p":true,"v":)"; + if (!data.starts_with(prefix) || data.size() <= prefix.size() || + data.back() != '}') { + throw serialization_error("Malformed optional serializer envelope"); + } + const auto encoded = + data.substr(prefix.size(), data.size() - prefix.size() - 1U); + return serializer_.deserialize(detail::json_unquote(encoded), context); + } + + private: + Serializer serializer_; +}; + +template <> +struct default_serdes { + [[nodiscard]] std::string serialize( + const std::string& value, const serdes_context&) const { + return detail::json_quote(value); + } + + [[nodiscard]] std::string deserialize( + std::string_view data, const serdes_context&) const { + return detail::json_unquote(data); + } +}; + +template <> +struct default_serdes { + [[nodiscard]] std::string serialize(bool value, const serdes_context&) const { + return value ? "true" : "false"; + } + + [[nodiscard]] bool deserialize( + std::string_view data, const serdes_context&) const { + if (data == "true") return true; + if (data == "false") return false; + throw serialization_error("Expected a JSON boolean"); + } +}; + +template + requires(!std::same_as, bool>) +struct default_serdes { + [[nodiscard]] std::string serialize(T value, const serdes_context&) const { + char buffer[std::numeric_limits::digits10 + 4]; + const auto [end, error] = std::to_chars( + std::begin(buffer), std::end(buffer), value); + if (error != std::errc{}) { + throw serialization_error("Failed to serialize an integer"); + } + return {buffer, end}; + } + + [[nodiscard]] T deserialize( + std::string_view data, const serdes_context&) const { + T value{}; + const auto [end, error] = + std::from_chars(data.data(), data.data() + data.size(), value); + if (error != std::errc{} || end != data.data() + data.size()) { + throw serialization_error("Expected a JSON integer"); + } + return value; + } +}; + +template +struct default_serdes { + [[nodiscard]] std::string serialize(T value, const serdes_context&) const { + if (!std::isfinite(value)) { + throw serialization_error("JSON cannot represent non-finite numbers"); + } + char buffer[64]; + const auto [end, error] = std::to_chars( + std::begin(buffer), std::end(buffer), value, + std::chars_format::general); + if (error != std::errc{}) { + throw serialization_error("Failed to serialize a floating-point value"); + } + return {buffer, end}; + } + + [[nodiscard]] T deserialize( + std::string_view data, const serdes_context&) const { + T value{}; + const auto [end, error] = std::from_chars( + data.data(), data.data() + data.size(), value, + std::chars_format::general); + if (error != std::errc{} || end != data.data() + data.size() || + !std::isfinite(value)) { + throw serialization_error("Expected a finite JSON number"); + } + return value; + } +}; + +template <> +struct default_serdes { + [[nodiscard]] std::string serialize( + std::monostate, const serdes_context&) const { + return "null"; + } + + [[nodiscard]] std::monostate deserialize( + std::string_view data, const serdes_context&) const { + if (data != "null") { + throw serialization_error("Expected JSON null"); + } + return {}; + } +}; + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/service_client.hpp b/include/aws/durable_execution/service_client.hpp new file mode 100644 index 0000000..592cf0d --- /dev/null +++ b/include/aws/durable_execution/service_client.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +#include "aws/durable_execution/model.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct service_error { + std::string message; + bool retryable{true}; +}; + +class service_client { + public: + virtual ~service_client() = default; + + [[nodiscard]] virtual std::expected checkpoint( + const checkpoint_request& request) = 0; + + [[nodiscard]] virtual std::expected + get_execution_state(const get_state_request& request) = 0; +}; + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/version.hpp b/include/aws/durable_execution/version.hpp new file mode 100644 index 0000000..f410d31 --- /dev/null +++ b/include/aws/durable_execution/version.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace aws::durable_execution { +inline namespace v1 { + +inline constexpr std::string_view version = "0.1.0"; +inline constexpr std::string_view abi_version = "1"; + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/wait_for_callback.hpp b/include/aws/durable_execution/wait_for_callback.hpp new file mode 100644 index 0000000..a72595a --- /dev/null +++ b/include/aws/durable_execution/wait_for_callback.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/callback.hpp" +#include "aws/durable_execution/child_context.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct wait_for_callback_config { + std::optional name; + std::chrono::seconds timeout{0}; + std::chrono::seconds heartbeat_timeout{0}; + retry_strategy submitter_retry{}; + step_semantics submitter_semantics{ + step_semantics::at_least_once_per_retry}; +}; + +namespace detail { + +template +decltype(auto) invoke_callback_submitter( + Submitter& submitter, std::string_view callback_id) { + if constexpr (std::invocable) { + return std::invoke(submitter, callback_id); + } else if constexpr (std::invocable) { + return std::invoke(submitter); + } else { + static_assert( + std::invocable, + "A callback submitter must accept std::string_view or no arguments"); + } +} + +} // namespace detail + +template < + typename Result = std::string, typename Submitter, + typename Serializer = detail::callback_default_serdes_t> + requires( + (std::invocable || + std::invocable) && + serializer_for && + std::copy_constructible) +[[nodiscard]] std::optional wait_for_callback( + Submitter&& submitter, wait_for_callback_config config = {}, + Serializer serializer = {}) { + const std::string callback_name = + config.name ? *config.name + "-callback" : "callback"; + const std::string submitter_name = + config.name ? *config.name + "-submitter" : "submitter"; + + optional_serdes child_serializer{serializer}; + return run_in_child_context( + [&]() -> std::optional { + auto callback = create_callback( + callback_config{ + .name = callback_name, + .timeout = config.timeout, + .heartbeat_timeout = config.heartbeat_timeout, + }, + serializer); + step( + [&] { + detail::invoke_callback_submitter( + submitter, callback.callback_id()); + }, + step_config{ + .name = submitter_name, + .retry = config.submitter_retry, + .semantics = config.submitter_semantics, + }); + return callback.result(); + }, + std::move(child_serializer), + child_context_config{ + .name = config.name, + .is_virtual = false, + .sub_type = + std::string{operation_subtype::wait_for_callback}, + }); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/wait_for_condition.hpp b/include/aws/durable_execution/wait_for_condition.hpp new file mode 100644 index 0000000..d8cdbb0 --- /dev/null +++ b/include/aws/durable_execution/wait_for_condition.hpp @@ -0,0 +1,308 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/model.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +class wait_for_condition_error final : public durable_error { + public: + explicit wait_for_condition_error(std::string message) + : durable_error(error_code::callable_failed, std::move(message)) {} +}; + +struct polling_strategy { + std::uint32_t max_attempts{6}; + std::chrono::seconds initial_delay{5}; + std::chrono::seconds max_delay{60}; + double backoff_rate{2.0}; + jitter_strategy jitter{jitter_strategy::full}; + std::optional increment; + + template + requires requires(const State& value) { + { static_cast(value) } -> std::same_as; + } + [[nodiscard]] std::optional operator()( + const State& state, std::uint32_t attempts_made) const { + if (static_cast(state)) { + return std::nullopt; + } + if (attempts_made >= max_attempts) { + throw wait_for_condition_error( + "wait_for_condition exhausted " + + std::to_string(max_attempts) + + " attempts before the condition was met"); + } + + double base = 0.0; + if (increment) { + base = static_cast(initial_delay.count()) + + static_cast(increment->count()) * + static_cast(attempts_made - 1U); + } else { + base = static_cast(initial_delay.count()) * + std::pow( + backoff_rate, + static_cast(attempts_made - 1U)); + } + base = std::min(base, static_cast(max_delay.count())); + if (jitter != jitter_strategy::none) { + thread_local std::minstd_rand generator{std::random_device{}()}; + std::uniform_real_distribution distribution(0.0, 1.0); + const double sample = distribution(generator); + base = jitter == jitter_strategy::half + ? base / 2.0 + sample * (base / 2.0) + : sample * base; + } + return std::chrono::seconds{ + std::max( + 1, static_cast(std::ceil(base)))}; + } +}; + +struct wait_for_condition_config { + std::optional name; +}; + +namespace detail { + +template +State invoke_condition_check( + Check& check, const std::optional& current, + std::uint32_t attempt) { + if constexpr ( + std::invocable&, std::uint32_t>) { + return std::invoke(check, current, attempt); + } else if constexpr ( + std::invocable&>) { + return std::invoke(check, current); + } else if constexpr ( + std::invocable) { + if (!current) { + throw invalid_state_error( + "wait_for_condition check requires an initial state"); + } + return std::invoke(check, *current, attempt); + } else if constexpr (std::invocable) { + if (!current) { + throw invalid_state_error( + "wait_for_condition check requires an initial state"); + } + return std::invoke(check, *current); + } else if constexpr (std::invocable) { + return std::invoke(check); + } else { + static_assert( + std::invocable&, std::uint32_t>, + "A condition check must accept optional state and/or attempt"); + } +} + +template +[[nodiscard]] std::optional condition_delay( + Strategy& strategy, const State& state, std::uint32_t attempt) { + static_assert( + std::invocable, + "A polling strategy must accept (const State&, uint32_t)"); + using decision_type = + std::invoke_result_t; + static_assert( + std::same_as< + std::remove_cvref_t, + std::optional>, + "A polling strategy must return optional"); + return std::invoke(strategy, state, attempt); +} + +[[nodiscard]] inline error_object condition_error( + const std::exception& exception) { + auto error = exception_to_error(exception); + if (dynamic_cast(&exception)) { + error.type = "WaitForConditionError"; + } + return error; +} + +} // namespace detail + +template < + typename State, typename Check, + typename Strategy = polling_strategy, + typename Serializer = default_serdes> + requires serializer_for +[[nodiscard]] State wait_for_condition( + Check&& check, + std::optional initial_state = std::nullopt, + Strategy strategy = {}, + wait_for_condition_config config = {}, + Serializer serializer = {}) { + auto& context = current_context(); + const auto identifier = context.reserve_operation( + operation_subtype::wait_for_condition, operation_type::step, + config.name ? std::optional{*config.name} + : std::nullopt); + const auto& id = identifier.require_operation_id(); + auto& state = context.state(); + auto existing = state.find_operation(id); + const bool operation_existed = static_cast(existing); + + if (existing) { + detail::validate_replay_identity(*existing, identifier); + if (!existing->status.is_known()) { + throw invalid_state_error( + "wait_for_condition has an unknown future status: " + + std::string{existing->status.wire_value()}); + } + if (existing->status == operation_status::succeeded) { + context.before_operation(id, false); + if (!existing->step || !existing->step->result) { + throw invalid_state_error( + "Succeeded wait_for_condition has no state payload"); + } + return serializer.deserialize( + *existing->step->result, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + } + if (existing->status == operation_status::failed || + existing->status == operation_status::cancelled || + existing->status == operation_status::timed_out || + existing->status == operation_status::stopped) { + const error_object* error = + existing->step && existing->step->error + ? &*existing->step->error + : nullptr; + if (error && error->type == "WaitForConditionError") { + throw wait_for_condition_error( + error->message.value_or("wait_for_condition failed")); + } + throw callable_error( + error && error->message + ? *error->message + : "wait_for_condition failed without an ErrorObject", + error && error->type ? *error->type : std::string{}); + } + if (existing->status == operation_status::pending) { + context.before_operation(id, false); + std::optional resume_after; + if (existing->step && + existing->step->next_attempt_timestamp) { + resume_after = std::max( + std::chrono::duration_cast( + *existing->step->next_attempt_timestamp - + std::chrono::system_clock::now()), + std::chrono::seconds::zero()); + } + throw execution_suspended( + "wait_for_condition is pending: " + id, resume_after); + } + } + + context.before_operation(id, true); + const auto replay_operation = existing; + if (!existing || existing->status == operation_status::ready) { + state.checkpoint(operation_update::step_start(identifier)); + } + + std::optional current = std::move(initial_state); + std::uint32_t attempt = 1; + if (replay_operation && replay_operation->step) { + attempt = replay_operation->step->attempt + 1U; + if (replay_operation->step->result) { + current = serializer.deserialize( + *replay_operation->step->result, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + } + } + + const auto attempt_started = std::chrono::system_clock::now(); + bool attempt_notified = false; + state.notify_attempt_start( + id, attempt, attempt_started, operation_existed); + try { + State new_state = [&] { + scoped_non_durable_region user_code_scope; + return detail::invoke_condition_check( + check, current, attempt); + }(); + state.notify_attempt_end( + id, attempt, attempt_started, + std::chrono::system_clock::now(), true, nullptr, + operation_existed); + attempt_notified = true; + const auto payload = serializer.serialize( + new_state, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + auto delay = + detail::condition_delay(strategy, new_state, attempt); + if (!delay) { + state.checkpoint( + operation_update::step_succeed(identifier, payload)); + return serializer.deserialize( + payload, + serdes_context{ + .operation_id = id, + .durable_execution_arn = state.durable_execution_arn(), + .recursive_level = state.recursive_level(), + }); + } + if (*delay < std::chrono::seconds{1}) { + *delay = std::chrono::seconds{1}; + } + state.checkpoint(operation_update::step_retry( + identifier, std::nullopt, + static_cast(delay->count()), payload)); + throw execution_suspended( + "wait_for_condition scheduled another check: " + id, delay); + } catch (const execution_suspended&) { + throw; + } catch (const checkpoint_error&) { + throw; + } catch (const state_fetch_error&) { + throw; + } catch (const std::exception& exception) { + const auto serialized_error = + detail::condition_error(exception); + if (!attempt_notified) { + state.notify_attempt_end( + id, attempt, attempt_started, + std::chrono::system_clock::now(), false, + &serialized_error, operation_existed); + } + state.checkpoint(operation_update::step_fail( + identifier, serialized_error)); + throw; + } +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/wire.hpp b/include/aws/durable_execution/wire.hpp new file mode 100644 index 0000000..cdcc40f --- /dev/null +++ b/include/aws/durable_execution/wire.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "aws/durable_execution/model.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct wire_error { + std::string message; + std::size_t offset{0}; +}; + +[[nodiscard]] std::expected +decode_invocation_input(std::string_view json); + +[[nodiscard]] std::expected +decode_invocation_output(std::string_view json); + +[[nodiscard]] std::string encode_invocation_output( + const invocation_output& output); + +[[nodiscard]] std::expected, wire_error> +json_object_integer_field( + std::string_view json, std::string_view field_name); + +[[nodiscard]] std::expected +set_json_object_integer_field( + std::string_view json, std::string_view field_name, + std::int64_t value); + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/include/aws/durable_execution/with_retry.hpp b/include/aws/durable_execution/with_retry.hpp new file mode 100644 index 0000000..6051ac2 --- /dev/null +++ b/include/aws/durable_execution/with_retry.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/child_context.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/operations.hpp" +#include "aws/durable_execution/serdes.hpp" + +namespace aws::durable_execution { +inline namespace v1 { + +struct with_retry_config { + std::optional name; + retry_strategy retry{}; + std::function( + const std::exception&, std::uint32_t)> + retry_decider; + bool is_virtual{false}; +}; + +namespace detail { + +template +decltype(auto) invoke_retry_body( + Function& function, std::uint32_t attempt) { + if constexpr (std::invocable) { + return std::invoke(function, attempt); + } else if constexpr (std::invocable) { + return std::invoke(function); + } else { + static_assert( + std::invocable, + "A with_retry callable must accept uint32_t attempt or no arguments"); + } +} + +template +using retry_raw_result_t = decltype(invoke_retry_body( + std::declval(), std::uint32_t{1})); + +template +using retry_result_t = std::conditional_t< + std::is_void_v>, std::monostate, + std::remove_cvref_t>>; + +} // namespace detail + +template < + typename Function, + typename Result = detail::retry_result_t, + typename Serializer = default_serdes> + requires( + (std::invocable || + std::invocable) && + serializer_for) +[[nodiscard]] auto with_retry( + Function&& function, with_retry_config config = {}, + Serializer serializer = {}) + -> std::remove_cvref_t> { + using raw_result = detail::retry_raw_result_t; + const std::string name = config.name.value_or("with-retry"); + + return run_in_child_context( + [&]() -> std::remove_cvref_t { + std::uint32_t attempt = 0; + while (true) { + ++attempt; + try { + if constexpr (std::is_void_v) { + detail::invoke_retry_body(function, attempt); + return; + } else { + return detail::invoke_retry_body(function, attempt); + } + } catch (const execution_suspended&) { + throw; + } catch (const checkpoint_error&) { + throw; + } catch (const state_fetch_error&) { + throw; + } catch (const invalid_state_error&) { + throw; + } catch (const serialization_error&) { + throw; + } catch (const std::exception& exception) { + std::optional delay; + if (config.retry_decider) { + delay = config.retry_decider(exception, attempt); + } else { + delay = config.retry.delay_for(attempt); + } + if (!delay) { + throw; + } + if (*delay < std::chrono::seconds{1}) { + *delay = std::chrono::seconds{1}; + } + wait( + *delay, + name + "-backoff-" + std::to_string(attempt)); + } + } + }, + std::move(serializer), + child_context_config{ + .name = name, + .is_virtual = config.is_virtual, + }); +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/scripts/build_conformance_al2023.sh b/scripts/build_conformance_al2023.sh new file mode 100755 index 0000000..2d3c774 --- /dev/null +++ b/scripts/build_conformance_al2023.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) + +: "${AWS_SDK_CPP_SOURCE:?Set AWS_SDK_CPP_SOURCE to an aws-sdk-cpp checkout}" +: "${AWS_LAMBDA_CPP_SOURCE:?Set AWS_LAMBDA_CPP_SOURCE to an aws-lambda-cpp checkout}" + +image=${AL2023_IMAGE:-public.ecr.aws/amazonlinux/amazonlinux:2023} +jobs=${JOBS:-2} +build_root=${BUILD_ROOT:-"$repo_root/.cache/conformance-al2023"} +output=${OUTPUT:-"$repo_root/conformance/durable_execution_conformance.zip"} + +for required in \ + "$AWS_SDK_CPP_SOURCE/CMakeLists.txt" \ + "$AWS_SDK_CPP_SOURCE/cmake/resolve_platform.cmake" \ + "$AWS_SDK_CPP_SOURCE/toolchains/cmakeProjectConfig.cmake" \ + "$AWS_SDK_CPP_SOURCE/src/aws-cpp-sdk-core/source/Aws.cpp" \ + "$AWS_SDK_CPP_SOURCE/generated/src/aws-cpp-sdk-lambda/CMakeLists.txt" \ + "$AWS_SDK_CPP_SOURCE/crt/aws-crt-cpp/CMakeLists.txt" \ + "$AWS_LAMBDA_CPP_SOURCE/CMakeLists.txt"; do + if [[ ! -f "$required" ]]; then + echo "Required dependency source is missing: $required" >&2 + exit 2 + fi +done + +mkdir -p "$build_root" "$(dirname "$output")" +build_root=$(cd "$build_root" && pwd) +aws_source=$(cd "$AWS_SDK_CPP_SOURCE" && pwd) +lambda_source=$(cd "$AWS_LAMBDA_CPP_SOURCE" && pwd) + +# aws-sdk-cpp 1.11's legacy build generates VersionConfig.h and SDKConfig.h in +# its source tree, so that checkout is intentionally mounted read/write. +docker run --rm \ + -e JOBS="$jobs" \ + -v "$aws_source:/src/aws-sdk" \ + -v "$lambda_source:/src/lambda-runtime:ro" \ + -v "$repo_root:/src/project:ro" \ + -v "$build_root:/work" \ + "$image" \ + bash -lc ' + set -euo pipefail + + dnf -q -y install \ + git \ + gcc14 \ + gcc14-c++ \ + gcc14-libstdc++-devel \ + libcurl-devel \ + libuuid-devel \ + make \ + ninja-build \ + openssl-devel \ + perl \ + python3-pip \ + zip \ + zlib-devel \ + >/tmp/dnf.log + + python3 -m pip install --no-cache-dir -q cmake==3.31.6 + + export CC=/usr/bin/gcc14-gcc + export CXX=/usr/bin/gcc14-g++ + + git config --global --add safe.directory /src/aws-sdk + git config --global --add safe.directory /src/aws-sdk/crt/aws-crt-cpp + + cmake \ + -S /src/aws-sdk \ + -B /work/aws-sdk-build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/work/prefix \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DBUILD_ONLY=lambda \ + -DBUILD_SHARED_LIBS=OFF \ + -DFORCE_SHARED_CRT=OFF \ + -DENABLE_TESTING=OFF \ + -DAUTORUN_UNIT_TESTS=OFF \ + -DMINIMIZE_SIZE=ON \ + -DBUILD_DEPS=ON + cmake --build /work/aws-sdk-build --target install -j"$JOBS" + + cmake \ + -S /src/lambda-runtime \ + -B /work/lambda-runtime-build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/work/prefix \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DAWS_LAMBDA_CPP_VERSION=1.0.1 \ + -DENABLE_TESTS=OFF + cmake --build /work/lambda-runtime-build --target install -j"$JOBS" + + cmake \ + -S /src/project \ + -B /work/project-build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH=/work/prefix \ + -DDURABLE_EXECUTION_BUILD_TESTS=OFF \ + -DDURABLE_EXECUTION_BUILD_EXAMPLES=OFF \ + -DDURABLE_EXECUTION_BUILD_AWS_SDK_ADAPTER=ON \ + -DDURABLE_EXECUTION_BUILD_LAMBDA_RUNTIME_ADAPTER=ON \ + -DDURABLE_EXECUTION_BUILD_CONFORMANCE=ON + cmake --build \ + /work/project-build \ + --target aws-lambda-package-durable_execution_conformance \ + -j"$JOBS" + ' + +artifact="$build_root/project-build/durable_execution_conformance.zip" +if [[ ! -f "$artifact" ]]; then + echo "Conformance package was not produced: $artifact" >&2 + exit 3 +fi + +cp "$artifact" "$output" +unzip -t "$output" >/dev/null +echo "Created $output" +sha256sum "$output" diff --git a/scripts/generate_conformance_template.py b/scripts/generate_conformance_template.py new file mode 100644 index 0000000..b8f7858 --- /dev/null +++ b/scripts/generate_conformance_template.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Generate a SAM template and explicit conformance coverage report.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +SUITES = { + "step": ("StepBasic", "step_basic"), + "wait": ("WaitBasic", "wait_basic"), + "child": ("ChildBasic", "child_basic"), + "callback": ("CallbackBasic", "callback_basic"), + "invoke": ("InvokeBasic", "invoke_basic"), + "wait_for_condition": ( + "WaitForConditionBasic", + "wait_for_condition_basic", + ), + "wait_for_callback": ( + "WaitForCallbackBasic", + "wait_for_callback_basic", + ), + "parallel": ("ParallelBasic", "parallel_basic"), + "map": ("MapBasic", "map_basic"), + "plugin": ("PluginInvocationLifecycle", "plugin_invocation_lifecycle"), +} + + +def discover_requirement_ids(root: Path) -> list[str]: + ids: list[str] = [] + for suite in SUITES: + ids.extend(path.stem for path in (root / suite).glob("*.yaml")) + return sorted( + ids, + key=lambda value: tuple(int(part) for part in value.split("-")), + ) + + +def load_supported(path: Path) -> dict[str, str]: + data = json.loads(path.read_text(encoding="utf-8")) + return { + str(requirement_id): str(case_name) + for requirement_id, case_name in data["cases"].items() + } + + +def function_resource( + *, + role: str, + code_uri: str, + case_name: str, + requirement_id: str | None, + environment: dict | None = None, + tenant_isolation: bool = False, +) -> dict: + variables = {"CONFORMANCE_CASE": case_name} + variables.update(environment or {}) + resource = { + "Type": "AWS::Serverless::Function", + "Properties": { + "Runtime": "provided.al2023", + "Architectures": ["x86_64"], + "Handler": "bootstrap", + "CodeUri": code_uri, + "Timeout": 60, + "MemorySize": 256, + "Role": {"Fn::GetAtt": [role, "Arn"]}, + "DurableConfig": { + "RetentionPeriodInDays": 7, + "ExecutionTimeout": 900, + }, + "Environment": {"Variables": variables}, + }, + } + if tenant_isolation: + resource["Properties"]["TenancyConfig"] = { + "TenantIsolationMode": "PER_TENANT" + } + if requirement_id is not None: + resource["TestingMetadata"] = { + "TestDescription": [requirement_id] + } + return resource + + +def build_template( + requirements: list[str], + supported: dict[str, str], + *, + code_uri: str, +) -> dict: + resources: dict[str, dict] = { + "DurableFunctionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole", + } + ], + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/" + "AWSLambdaBasicDurableExecutionRolePolicy" + ], + "Policies": [ + { + "PolicyName": "ConformanceOperations", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction", + "lambda:SendDurableExecutionCallbackSuccess", + "lambda:SendDurableExecutionCallbackFailure", + "lambda:SendDurableExecutionCallbackHeartbeat", + ], + "Resource": "*", + } + ], + }, + } + ], + }, + }, + "TargetEcho": function_resource( + role="DurableFunctionRole", + code_uri=code_uri, + case_name="target_echo", + requirement_id=None, + ), + "TargetError": function_resource( + role="DurableFunctionRole", + code_uri=code_uri, + case_name="target_error", + requirement_id=None, + ), + "TargetEchoTenant": function_resource( + role="DurableFunctionRole", + code_uri=code_uri, + case_name="target_echo", + requirement_id=None, + tenant_isolation=True, + ), + } + + for requirement_id, case_name in sorted( + supported.items(), + key=lambda item: tuple( + int(part) for part in item[0].split("-") + ), + ): + logical_id = "".join( + word[:1].upper() + word[1:] + for word in case_name.split("_") + ) + environment = None + if requirement_id.startswith("5-"): + target_resource = ( + "TargetEchoTenant" + if requirement_id == "5-8" + else "TargetEcho" + ) + environment = { + "TARGET_FUNCTION_NAME": { + "Fn::Sub": f"${{{target_resource}.Arn}}:$LATEST" + }, + "ERROR_FUNCTION_NAME": { + "Fn::Sub": "${TargetError.Arn}:$LATEST" + }, + } + resources[logical_id] = function_resource( + role="DurableFunctionRole", + code_uri=code_uri, + case_name=case_name, + requirement_id=requirement_id, + environment=environment, + ) + + unsupported = [ + { + "id": requirement_id, + "reason": ( + "C++ conformance handler mapping is not implemented yet" + ), + } + for requirement_id in requirements + if requirement_id not in supported + ] + first_logical_id = "".join( + word[:1].upper() + word[1:] + for word in next(iter(supported.values())).split("_") + ) + first_resource = resources[first_logical_id] + first_resource.setdefault("TestingMetadata", {})[ + "NotImplemented" + ] = unsupported + + return { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": "AWS::Serverless-2016-10-31", + "Description": ( + "AWS Durable Execution C++ conformance handlers" + ), + "Resources": resources, + } + + +def main() -> int: + repo = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser() + parser.add_argument("--requirements-dir", type=Path, required=True) + parser.add_argument( + "--supported", + type=Path, + default=repo / "conformance" / "supported.json", + ) + parser.add_argument( + "--output", + type=Path, + default=repo / "conformance" / "template.json", + ) + parser.add_argument( + "--coverage-output", + type=Path, + default=repo / "conformance" / "coverage.json", + ) + parser.add_argument( + "--code-uri", + default="durable_execution_conformance.zip", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Fail if any official requirement is not mapped.", + ) + args = parser.parse_args() + + requirements = discover_requirement_ids(args.requirements_dir) + supported = load_supported(args.supported) + missing_supported = sorted(set(supported) - set(requirements)) + if missing_supported: + raise ValueError( + f"Supported IDs absent from requirement set: {missing_supported}" + ) + unsupported = [ + requirement_id + for requirement_id in requirements + if requirement_id not in supported + ] + if args.strict and unsupported: + raise SystemExit( + f"{len(unsupported)} conformance requirements remain unmapped" + ) + + template = build_template( + requirements, supported, code_uri=args.code_uri + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(template, indent=2) + "\n", encoding="utf-8" + ) + args.coverage_output.write_text( + json.dumps( + { + "total": len(requirements), + "supported": len(supported), + "unsupported": len(unsupported), + "supported_ids": sorted( + supported, + key=lambda value: tuple( + int(part) for part in value.split("-") + ), + ), + "unsupported_ids": unsupported, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + print( + f"Mapped {len(supported)}/{len(requirements)} requirements; " + f"{len(unsupported)} declared NotImplemented" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_cross_language_fixtures.py b/scripts/generate_cross_language_fixtures.py new file mode 100644 index 0000000..90d6c95 --- /dev/null +++ b/scripts/generate_cross_language_fixtures.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Generate C++ compatibility fixtures from async-durable-execution.""" + +from __future__ import annotations + +import argparse +import asyncio +import datetime +import hashlib +import json +import uuid +from pathlib import Path + +from async_durable_execution._core.execution import ( + DurableExecutionInvocationInput, + InitialExecutionState, +) +from async_durable_execution._core.models import ( + CallbackDetails, + ChainedInvokeDetails, + ContextDetails, + ErrorObject, + ExecutionDetails, + Operation, + OperationStatus, + OperationSubType, + OperationType, + StepDetails, + WaitDetails, +) +from async_durable_execution._core.serdes import ExtendedTypeSerDes + + +UTC = datetime.timezone.utc + + +def invocation_fixture() -> dict: + operations = [ + Operation( + "fixture-execution", + OperationType.EXECUTION, + OperationStatus.STARTED, + name="fixture", + sub_type=OperationSubType.EXECUTION, + start_timestamp=datetime.datetime( + 2026, 8, 26, 12, 0, 0, 123000, tzinfo=UTC + ), + execution_details=ExecutionDetails( + input_payload='{"value":1}' + ), + ), + Operation( + "step-1", + OperationType.STEP, + OperationStatus.PENDING, + name="poll", + sub_type=OperationSubType.WAIT_FOR_CONDITION, + start_timestamp=datetime.datetime( + 2026, 8, 26, 12, 0, 1, tzinfo=UTC + ), + step_details=StepDetails( + attempt=2, + next_attempt_timestamp=datetime.datetime( + 2026, 8, 26, 12, 0, 6, tzinfo=UTC + ), + result="2", + error=ErrorObject(message="retry", type="Retryable"), + ), + ), + Operation( + "wait-1", + OperationType.WAIT, + OperationStatus.STARTED, + name="wait", + sub_type=OperationSubType.WAIT, + wait_details=WaitDetails( + scheduled_end_timestamp=datetime.datetime( + 2026, 8, 26, 12, 1, 0, tzinfo=UTC + ) + ), + ), + Operation( + "callback-1", + OperationType.CALLBACK, + OperationStatus.STARTED, + name="approval", + sub_type=OperationSubType.CALLBACK, + callback_details=CallbackDetails( + callback_id="callback-token" + ), + ), + Operation( + "invoke-1", + OperationType.CHAINED_INVOKE, + OperationStatus.SUCCEEDED, + name="invoke", + sub_type=OperationSubType.CHAINED_INVOKE, + chained_invoke_details=ChainedInvokeDetails( + result='"done"' + ), + ), + Operation( + "context-1", + OperationType.CONTEXT, + OperationStatus.SUCCEEDED, + name="child", + sub_type=OperationSubType.RUN_IN_CHILD_CONTEXT, + context_details=ContextDetails( + replay_children=True, result="summary" + ), + ), + ] + return DurableExecutionInvocationInput( + durable_execution_arn=( + "arn:aws:lambda:us-east-1:123456789012:" + "function:fixture/fixture-execution" + ), + checkpoint_token="fixture-token-7", + initial_execution_state=InitialExecutionState( + operations=operations, next_marker="next-page" + ), + ).to_dict() + + +def operation_id_rows() -> list[tuple[str, str, str, str]]: + rows = [] + for kind, prefix, value in [ + ("sequential", "", "1"), + ("sequential", "root", "1"), + ("local", "", "alpha"), + ("local", "root", "alpha"), + ]: + identity = ( + f"local:{value}" if kind == "local" else value + ) + source = f"{prefix}-{identity}" if prefix else identity + digest = hashlib.blake2b(source.encode()).hexdigest()[:64] + rows.append((kind, prefix, value, digest)) + return rows + + +async def serdes_rows() -> list[tuple[str, str]]: + serializer = ExtendedTypeSerDes() + values = [ + ("null", None), + ("bool_true", True), + ("int_negative", -42), + ("float", 3.25), + ("string", "hello\nworld"), + ( + "uuid", + uuid.UUID("12345678-1234-4abc-8def-1234567890ab"), + ), + ( + "datetime", + datetime.datetime( + 2026, 8, 26, 12, 34, 56, 789000, tzinfo=UTC + ), + ), + ] + return [ + (name, await serializer.serialize(value)) + for name, value in values + ] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output-dir", + type=Path, + default=Path(__file__).resolve().parents[1] + / "tests" + / "fixtures", + ) + args = parser.parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + + (args.output_dir / "python_invocation.json").write_text( + json.dumps( + invocation_fixture(), + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) + (args.output_dir / "python_operation_ids.tsv").write_text( + "".join("\t".join(row) + "\n" for row in operation_id_rows()), + encoding="utf-8", + ) + (args.output_dir / "python_serdes.tsv").write_text( + "".join( + f"{name}\t{payload}\n" + for name, payload in asyncio.run(serdes_rows()) + ), + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_conformance_assets.py b/scripts/validate_conformance_assets.py new file mode 100644 index 0000000..345a46b --- /dev/null +++ b/scripts/validate_conformance_assets.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Validate generated conformance coverage and SAM metadata.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def main() -> int: + repo = Path(__file__).resolve().parents[1] + supported_data = json.loads( + (repo / "conformance" / "supported.json").read_text( + encoding="utf-8" + ) + ) + coverage = json.loads( + (repo / "conformance" / "coverage.json").read_text( + encoding="utf-8" + ) + ) + template = json.loads( + (repo / "conformance" / "template.json").read_text( + encoding="utf-8" + ) + ) + + supported = set(supported_data["cases"]) + covered: set[str] = set() + unsupported: set[str] = set() + for resource in template["Resources"].values(): + metadata = resource.get("TestingMetadata", {}) + covered.update(metadata.get("TestDescription", [])) + unsupported.update( + entry["id"] + for entry in metadata.get("NotImplemented", []) + if "id" in entry + ) + + if covered != supported: + raise SystemExit( + f"Template coverage differs from supported manifest: " + f"{covered ^ supported}" + ) + if covered & unsupported: + raise SystemExit("Requirement IDs are both covered and unsupported") + if len(covered | unsupported) != coverage["total"]: + raise SystemExit("Template does not account for every requirement") + if coverage["supported"] != len(covered): + raise SystemExit("Coverage supported count is stale") + if coverage["unsupported"] != len(unsupported): + raise SystemExit("Coverage unsupported count is stale") + if set(coverage["supported_ids"]) != covered: + raise SystemExit("Coverage supported ID list is stale") + if set(coverage["unsupported_ids"]) != unsupported: + raise SystemExit("Coverage unsupported ID list is stale") + + functions = { + metadata["TestDescription"][0]: resource + for resource in template["Resources"].values() + if resource.get("Type") == "AWS::Serverless::Function" + and ( + metadata := resource.get("TestingMetadata", {}) + ).get("TestDescription") + } + for requirement_id, resource in functions.items(): + timeout = resource["Properties"]["DurableConfig"][ + "ExecutionTimeout" + ] + if timeout > 900: + raise SystemExit( + f"{requirement_id} cannot be invoked synchronously " + f"with ExecutionTimeout={timeout}" + ) + + tenant_invoke = functions["5-8"] + target_sub = tenant_invoke["Properties"]["Environment"]["Variables"][ + "TARGET_FUNCTION_NAME" + ]["Fn::Sub"] + if "TargetEchoTenant" not in target_sub: + raise SystemExit("5-8 does not target the tenant-enabled function") + tenant_target = template["Resources"]["TargetEchoTenant"] + if ( + tenant_target["Properties"] + .get("TenancyConfig", {}) + .get("TenantIsolationMode") + != "PER_TENANT" + ): + raise SystemExit("Tenant invoke target is not PER_TENANT") + + print( + f"Conformance assets valid: {len(covered)} covered, " + f"{len(unsupported)} explicitly unsupported" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_plugin_logs.py b/scripts/validate_plugin_logs.py new file mode 100755 index 0000000..989094d --- /dev/null +++ b/scripts/validate_plugin_logs.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Validate local plugin-handler logs with the official log matcher.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path +from typing import Any + +import yaml +from aws_durable_execution_conformance_tests.cloudwatch import ( + CloudWatchLogValidator, +) + + +GEN_STR = re.compile(r"^\$\{GEN_STR:(\d+)\}$") +PLACEHOLDER = re.compile(r"^\$\{[^}]+\}$") + + +def substitute(value: Any, variables: dict[str, str]) -> Any: + if isinstance(value, str): + for name, replacement in variables.items(): + value = value.replace(f"${{{name}}}", replacement) + return value + if isinstance(value, list): + return [substitute(item, variables) for item in value] + if isinstance(value, dict): + return { + key: substitute(item, variables) + for key, item in value.items() + } + return value + + +def relax_history_placeholders(value: Any) -> Any: + if isinstance(value, str) and PLACEHOLDER.fullmatch(value): + return "/.+/" + if isinstance(value, list): + return [relax_history_placeholders(item) for item in value] + if isinstance(value, dict): + return { + key: relax_history_placeholders(item) + for key, item in value.items() + } + return value + + +def case_variables(description: dict[str, Any]) -> dict[str, str]: + result: dict[str, str] = {} + for name, expression in (description.get("Variables") or {}).items(): + match = GEN_STR.fullmatch(str(expression)) + if match: + length = int(match.group(1)) + seed = (name.lower().replace("_", "") + "x" * length)[:length] + result[name] = seed + else: + result[name] = str(expression) + return result + + +def local_operations(stderr: str) -> list[dict[str, str]]: + operations: list[dict[str, str]] = [] + for line in stderr.splitlines(): + if not line.startswith("LOCAL_OPERATION\t"): + continue + fields = line.split("\t") + fields.extend([""] * (6 - len(fields))) + operations.append( + { + "id": fields[1], + "type": fields[2], + "sub_type": fields[3], + "name": fields[4], + "parent_id": fields[5], + } + ) + return operations + + +def bind_history_ids( + description: dict[str, Any], + operations: list[dict[str, str]], +) -> dict[str, str]: + type_by_event = { + "StepStarted": "STEP", + "WaitStarted": "WAIT", + "ContextStarted": "CONTEXT", + "CallbackStarted": "CALLBACK", + "ChainedInvokeStarted": "CHAINED_INVOKE", + } + bindings: dict[str, str] = {} + next_operation = 0 + for event in description.get("ExpectedExecutionHistory") or []: + placeholder = event.get("Id") + if not isinstance(placeholder, str): + continue + match = PLACEHOLDER.fullmatch(placeholder) + if match is None: + continue + placeholder_name = placeholder[2:-1] + if placeholder_name in bindings: + continue + expected_type = type_by_event.get(event.get("EventType")) + if expected_type is None: + continue + expected_sub_type = event.get("SubType") + for index in range(next_operation, len(operations)): + operation = operations[index] + if operation["type"] != expected_type: + continue + if ( + isinstance(expected_sub_type, str) + and not PLACEHOLDER.fullmatch(expected_sub_type) + and operation["sub_type"] != expected_sub_type + ): + continue + bindings[placeholder_name] = operation["id"] + next_operation = index + 1 + break + return bindings + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--requirements-dir", type=Path, required=True) + parser.add_argument("--runner", type=Path, required=True) + parser.add_argument( + "--supported", + type=Path, + default=Path("conformance/supported.json"), + ) + args = parser.parse_args() + + supported = json.loads( + args.supported.read_text(encoding="utf-8") + )["cases"] + validator = CloudWatchLogValidator() + failures: list[str] = [] + + plugin_dir = args.requirements_dir / "plugin" + paths = sorted( + plugin_dir.glob("10-*.yaml"), + key=lambda path: int(path.stem.split("-")[1]), + ) + for path in paths: + description = yaml.safe_load(path.read_text(encoding="utf-8")) + variables = case_variables(description) + input_value = substitute(description.get("Input"), variables) + input_json = json.dumps( + input_value, separators=(",", ":"), ensure_ascii=False + ) + case_name = supported[path.stem] + completed = subprocess.run( + [str(args.runner), case_name, input_json], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 0: + failures.append( + f"{path.stem}: local runner exited {completed.returncode}: " + f"{completed.stderr.strip()}" + ) + continue + + operation_bindings = bind_history_ids( + description, local_operations(completed.stderr) + ) + events = [ + { + "timestamp": index, + "ingestionTime": index, + "message": line, + } + for index, line in enumerate(completed.stdout.splitlines()) + ] + expected = relax_history_placeholders( + substitute( + description.get("ExpectedLogs") or [], + {**variables, **operation_bindings}, + ) + ) + result = validator.validate(expected, events) + if not result.success: + failures.extend( + f"{path.stem}: {error}" for error in result.errors + ) + + if failures: + print("\n".join(failures)) + return 1 + print(f"Official plugin log matcher passed for {len(paths)} cases") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/aws_sdk/service_client.cpp b/src/aws_sdk/service_client.cpp new file mode 100644 index 0000000..46bccb3 --- /dev/null +++ b/src/aws_sdk/service_client.cpp @@ -0,0 +1,456 @@ +#include "aws/durable_execution/aws_sdk_service_client.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace aws::durable_execution { +inline namespace v1 { +namespace { + +[[nodiscard]] Aws::String to_aws_string(std::string_view value) { + return Aws::String{value.data(), value.size()}; +} + +[[nodiscard]] std::string to_std_string(const Aws::String& value) { + return std::string{value.data(), value.size()}; +} + +[[nodiscard]] timestamp to_timestamp(const Aws::Utils::DateTime& value) { + return timestamp{std::chrono::duration_cast( + std::chrono::milliseconds{value.Millis()})}; +} + +[[nodiscard]] Aws::Lambda::Model::OperationType to_aws_operation_type( + operation_type value) noexcept { + using aws_type = Aws::Lambda::Model::OperationType; + switch (value) { + case operation_type::execution: return aws_type::EXECUTION; + case operation_type::context: return aws_type::CONTEXT; + case operation_type::step: return aws_type::STEP; + case operation_type::wait: return aws_type::WAIT; + case operation_type::callback: return aws_type::CALLBACK; + case operation_type::chained_invoke: return aws_type::CHAINED_INVOKE; + } + return aws_type::NOT_SET; +} + +[[nodiscard]] Aws::Lambda::Model::OperationAction to_aws_operation_action( + operation_action value) noexcept { + using aws_action = Aws::Lambda::Model::OperationAction; + switch (value) { + case operation_action::start: return aws_action::START; + case operation_action::succeed: return aws_action::SUCCEED; + case operation_action::fail: return aws_action::FAIL; + case operation_action::retry: return aws_action::RETRY; + case operation_action::cancel: return aws_action::CANCEL; + } + return aws_action::NOT_SET; +} + +[[nodiscard]] Aws::Lambda::Model::ErrorObject to_aws_error( + const error_object& source) { + Aws::Lambda::Model::ErrorObject result; + if (source.message) { + result.SetErrorMessage(to_aws_string(*source.message)); + } + if (source.type) { + result.SetErrorType(to_aws_string(*source.type)); + } + if (source.data) { + result.SetErrorData(to_aws_string(*source.data)); + } + for (const auto& frame : source.stack_trace) { + result.AddStackTrace(to_aws_string(frame)); + } + return result; +} + +[[nodiscard]] error_object from_aws_error( + const Aws::Lambda::Model::ErrorObject& source) { + error_object result; + if (source.ErrorMessageHasBeenSet()) { + result.message = to_std_string(source.GetErrorMessage()); + } + if (source.ErrorTypeHasBeenSet()) { + result.type = to_std_string(source.GetErrorType()); + } + if (source.ErrorDataHasBeenSet()) { + result.data = to_std_string(source.GetErrorData()); + } + if (source.StackTraceHasBeenSet()) { + result.stack_trace.reserve(source.GetStackTrace().size()); + for (const auto& frame : source.GetStackTrace()) { + result.stack_trace.push_back(to_std_string(frame)); + } + } + return result; +} + +[[nodiscard]] Aws::Lambda::Model::OperationUpdate to_aws_update( + const operation_update& source) { + Aws::Lambda::Model::OperationUpdate result; + result.SetId(to_aws_string(source.operation_id)); + result.SetType(to_aws_operation_type(source.type)); + result.SetAction(to_aws_operation_action(source.action)); + + if (source.parent_id) { + result.SetParentId(to_aws_string(*source.parent_id)); + } + if (source.name) { + result.SetName(to_aws_string(*source.name)); + } + if (source.sub_type) { + result.SetSubType(to_aws_string(*source.sub_type)); + } + if (source.payload) { + result.SetPayload(to_aws_string(*source.payload)); + } + if (source.error) { + result.SetError(to_aws_error(*source.error)); + } + if (source.context) { + Aws::Lambda::Model::ContextOptions options; + options.SetReplayChildren(source.context->replay_children); + result.SetContextOptions(std::move(options)); + } + if (source.step) { + Aws::Lambda::Model::StepOptions options; + options.SetNextAttemptDelaySeconds( + static_cast(source.step->next_attempt_delay_seconds)); + result.SetStepOptions(std::move(options)); + } + if (source.wait) { + Aws::Lambda::Model::WaitOptions options; + options.SetWaitSeconds(static_cast(source.wait->wait_seconds)); + result.SetWaitOptions(std::move(options)); + } + if (source.callback) { + Aws::Lambda::Model::CallbackOptions options; + options.SetTimeoutSeconds( + static_cast(source.callback->timeout_seconds)); + options.SetHeartbeatTimeoutSeconds( + static_cast(source.callback->heartbeat_timeout_seconds)); + result.SetCallbackOptions(std::move(options)); + } + if (source.chained_invoke) { + Aws::Lambda::Model::ChainedInvokeOptions options; + options.SetFunctionName( + to_aws_string(source.chained_invoke->function_name)); + if (source.chained_invoke->tenant_id) { + options.SetTenantId( + to_aws_string(*source.chained_invoke->tenant_id)); + } + result.SetChainedInvokeOptions(std::move(options)); + } + return result; +} + +[[nodiscard]] std::expected from_aws_operation( + const Aws::Lambda::Model::Operation& source) { + if (!source.IdHasBeenSet() || !source.TypeHasBeenSet() || + !source.StatusHasBeenSet()) { + return std::unexpected(service_error{ + .message = + "AWS durable state returned an operation without Id, Type, or Status", + .retryable = false, + }); + } + + const auto type_name = + Aws::Lambda::Model::OperationTypeMapper::GetNameForOperationType( + source.GetType()); + const auto status_name = + Aws::Lambda::Model::OperationStatusMapper::GetNameForOperationStatus( + source.GetStatus()); + if (type_name.empty() || status_name.empty()) { + return std::unexpected(service_error{ + .message = + "AWS durable state returned an operation with an unset wire enum", + .retryable = false, + }); + } + + operation result; + result.operation_id = to_std_string(source.GetId()); + result.type = operation_type_from_wire( + std::string_view{type_name.data(), type_name.size()}); + result.status = operation_status_from_wire( + std::string_view{status_name.data(), status_name.size()}); + if (source.ParentIdHasBeenSet()) { + result.parent_id = to_std_string(source.GetParentId()); + } + if (source.NameHasBeenSet()) { + result.name = to_std_string(source.GetName()); + } + if (source.StartTimestampHasBeenSet()) { + result.start_timestamp = to_timestamp(source.GetStartTimestamp()); + } + if (source.EndTimestampHasBeenSet()) { + result.end_timestamp = to_timestamp(source.GetEndTimestamp()); + } + if (source.SubTypeHasBeenSet()) { + result.sub_type = to_std_string(source.GetSubType()); + } + if (source.ExecutionDetailsHasBeenSet()) { + execution_details details; + if (source.GetExecutionDetails().InputPayloadHasBeenSet()) { + details.input_payload = + to_std_string(source.GetExecutionDetails().GetInputPayload()); + } + result.execution = std::move(details); + } + if (source.ContextDetailsHasBeenSet()) { + const auto& aws_details = source.GetContextDetails(); + context_details details; + if (aws_details.ReplayChildrenHasBeenSet()) { + details.replay_children = aws_details.GetReplayChildren(); + } + if (aws_details.ResultHasBeenSet()) { + details.result = to_std_string(aws_details.GetResult()); + } + if (aws_details.ErrorHasBeenSet()) { + details.error = from_aws_error(aws_details.GetError()); + } + result.context = std::move(details); + } + if (source.StepDetailsHasBeenSet()) { + const auto& aws_details = source.GetStepDetails(); + step_details details; + if (aws_details.AttemptHasBeenSet()) { + const int attempt = aws_details.GetAttempt(); + if (attempt < 0) { + return std::unexpected(service_error{ + .message = "AWS durable state returned a negative step attempt", + .retryable = false, + }); + } + details.attempt = static_cast(attempt); + } + if (aws_details.NextAttemptTimestampHasBeenSet()) { + details.next_attempt_timestamp = + to_timestamp(aws_details.GetNextAttemptTimestamp()); + } + if (aws_details.ResultHasBeenSet()) { + details.result = to_std_string(aws_details.GetResult()); + } + if (aws_details.ErrorHasBeenSet()) { + details.error = from_aws_error(aws_details.GetError()); + } + result.step = std::move(details); + } + if (source.WaitDetailsHasBeenSet()) { + wait_details details; + if (source.GetWaitDetails().ScheduledEndTimestampHasBeenSet()) { + details.scheduled_end_timestamp = + to_timestamp(source.GetWaitDetails().GetScheduledEndTimestamp()); + } + result.wait = std::move(details); + } + if (source.CallbackDetailsHasBeenSet()) { + const auto& aws_details = source.GetCallbackDetails(); + callback_details details; + if (aws_details.CallbackIdHasBeenSet()) { + details.callback_id = to_std_string(aws_details.GetCallbackId()); + } + if (aws_details.ResultHasBeenSet()) { + details.result = to_std_string(aws_details.GetResult()); + } + if (aws_details.ErrorHasBeenSet()) { + details.error = from_aws_error(aws_details.GetError()); + } + result.callback = std::move(details); + } + if (source.ChainedInvokeDetailsHasBeenSet()) { + const auto& aws_details = source.GetChainedInvokeDetails(); + chained_invoke_details details; + if (aws_details.ResultHasBeenSet()) { + details.result = to_std_string(aws_details.GetResult()); + } + if (aws_details.ErrorHasBeenSet()) { + details.error = from_aws_error(aws_details.GetError()); + } + result.chained_invoke = std::move(details); + } + return result; +} + +template +[[nodiscard]] std::expected, service_error> +from_aws_operations(const AwsOperations& source) { + std::vector result; + result.reserve(source.size()); + for (const auto& aws_operation : source) { + auto converted = from_aws_operation(aws_operation); + if (!converted) { + return std::unexpected(std::move(converted.error())); + } + result.push_back(std::move(*converted)); + } + return result; +} + +template +[[nodiscard]] service_error from_aws_service_error(const AwsError& error) { + std::string message; + if (!error.GetExceptionName().empty()) { + message = to_std_string(error.GetExceptionName()); + message.append(": "); + } + message.append(to_std_string(error.GetMessage())); + if (message.empty()) { + message = "AWS Lambda durable API request failed"; + } + return service_error{ + .message = std::move(message), + .retryable = error.ShouldRetry(), + }; +} + +} // namespace + +aws_sdk_service_client::aws_sdk_service_client( + const Aws::Lambda::LambdaClient& client) noexcept + : client_(&client) {} + +aws_sdk_service_client::aws_sdk_service_client( + std::shared_ptr client) + : owned_client_(std::move(client)), client_(owned_client_.get()) { + if (!client_) { + throw std::invalid_argument("AWS Lambda client must not be null"); + } +} + +std::expected +aws_sdk_service_client::checkpoint(const checkpoint_request& request) { + if (request.durable_execution_arn.empty() || + request.checkpoint_token.empty()) { + return std::unexpected(service_error{ + .message = + "Durable execution ARN and checkpoint token must not be empty", + .retryable = false, + }); + } + + Aws::Lambda::Model::CheckpointDurableExecutionRequest aws_request; + aws_request.SetDurableExecutionArn( + to_aws_string(request.durable_execution_arn)); + aws_request.SetCheckpointToken(to_aws_string(request.checkpoint_token)); + + Aws::Vector updates; + updates.reserve(request.updates.size()); + for (const auto& update : request.updates) { + if ((update.step && + update.step->next_attempt_delay_seconds > + static_cast(std::numeric_limits::max())) || + (update.wait && + update.wait->wait_seconds > + static_cast(std::numeric_limits::max())) || + (update.callback && + (update.callback->timeout_seconds > + static_cast(std::numeric_limits::max()) || + update.callback->heartbeat_timeout_seconds > + static_cast( + std::numeric_limits::max())))) { + return std::unexpected(service_error{ + .message = "Durable operation option exceeds the AWS SDK integer range", + .retryable = false, + }); + } + updates.push_back(to_aws_update(update)); + } + aws_request.SetUpdates(std::move(updates)); + if (request.client_token) { + aws_request.SetClientToken(to_aws_string(*request.client_token)); + } + + const auto outcome = client_->CheckpointDurableExecution(aws_request); + if (!outcome.IsSuccess()) { + return std::unexpected(from_aws_service_error(outcome.GetError())); + } + + const auto& aws_result = outcome.GetResult(); + auto operations = + from_aws_operations(aws_result.GetNewExecutionState().GetOperations()); + if (!operations) { + return std::unexpected(std::move(operations.error())); + } + + checkpoint_output result; + result.checkpoint_token = + aws_result.GetCheckpointToken().empty() + ? std::nullopt + : std::optional{ + to_std_string(aws_result.GetCheckpointToken())}; + result.operations = std::move(*operations); + const auto& marker = + aws_result.GetNewExecutionState().GetNextMarker(); + if (!marker.empty()) { + result.next_marker = to_std_string(marker); + } + return result; +} + +std::expected +aws_sdk_service_client::get_execution_state( + const get_state_request& request) { + if (request.durable_execution_arn.empty() || + request.checkpoint_token.empty()) { + return std::unexpected(service_error{ + .message = + "Durable execution ARN and checkpoint token must not be empty", + .retryable = false, + }); + } + if (request.max_items > + static_cast(std::numeric_limits::max())) { + return std::unexpected(service_error{ + .message = "max_items exceeds the AWS SDK integer range", + .retryable = false, + }); + } + + Aws::Lambda::Model::GetDurableExecutionStateRequest aws_request; + aws_request.SetDurableExecutionArn( + to_aws_string(request.durable_execution_arn)); + aws_request.SetCheckpointToken(to_aws_string(request.checkpoint_token)); + if (!request.marker.empty()) { + aws_request.SetMarker(to_aws_string(request.marker)); + } + aws_request.SetMaxItems(static_cast(request.max_items)); + + const auto outcome = client_->GetDurableExecutionState(aws_request); + if (!outcome.IsSuccess()) { + return std::unexpected(from_aws_service_error(outcome.GetError())); + } + + const auto& aws_result = outcome.GetResult(); + auto operations = from_aws_operations(aws_result.GetOperations()); + if (!operations) { + return std::unexpected(std::move(operations.error())); + } + + state_output result; + result.operations = std::move(*operations); + if (!aws_result.GetNextMarker().empty()) { + result.next_marker = to_std_string(aws_result.GetNextMarker()); + } + return result; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/src/context.cpp b/src/context.cpp new file mode 100644 index 0000000..b55aa19 --- /dev/null +++ b/src/context.cpp @@ -0,0 +1,227 @@ +#include "aws/durable_execution/context.hpp" + +#include +#include + +#include "aws/durable_execution/detail/blake2b.hpp" +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/execution_state.hpp" + +namespace aws::durable_execution { +inline namespace v1 { +namespace { + +thread_local durable_context* active_context = nullptr; +thread_local bool durable_operations_allowed = true; +thread_local detail::scoped_operation_reservation* + active_operation_reservation = nullptr; + +[[nodiscard]] bool is_terminal(const operation_status_value& status) noexcept { + if (!status.is_known()) { + return false; + } + switch (*status.known()) { + case operation_status::succeeded: + case operation_status::failed: + case operation_status::cancelled: + case operation_status::timed_out: + case operation_status::stopped: + return true; + case operation_status::started: + case operation_status::pending: + case operation_status::ready: + return false; + } + return false; +} + +} // namespace + +operation_id_generator::operation_id_generator( + std::optional prefix, std::uint64_t initial_counter) + : prefix_(std::move(prefix)), counter_(initial_counter) {} + +std::string operation_id_generator::make_id(std::string_view value) const { + if (!prefix_) { + return detail::blake2b_512_hex_64(value); + } + std::string identity; + identity.reserve(prefix_->size() + value.size() + 1); + identity.append(*prefix_); + identity.push_back('-'); + identity.append(value); + return detail::blake2b_512_hex_64(identity); +} + +std::string operation_id_generator::next() { + ++counter_; + char buffer[24]; + const auto [end, error] = + std::to_chars(std::begin(buffer), std::end(buffer), counter_); + if (error != std::errc{}) { + throw invalid_state_error("Failed to format durable operation counter"); + } + return make_id(std::string_view{buffer, end}); +} + +std::string operation_id_generator::reserve(std::string_view local_id) { + if (local_id.empty() || + local_id.find_first_not_of(" \t\r\n") == std::string_view::npos) { + throw durable_error( + error_code::invalid_argument, "local operation id must not be blank"); + } + if (!local_ids_.emplace(local_id).second) { + throw durable_error( + error_code::invalid_argument, + "local operation id is already reserved: " + std::string{local_id}); + } + std::string identity{"local:"}; + identity.append(local_id); + return make_id(identity); +} + +durable_context::durable_context( + execution_state& state, operation_identifier identifier, + std::optional step_id_prefix, bool replaying, + std::uint64_t initial_step) + : state_(&state), + identifier_(std::move(identifier)), + id_generator_( + step_id_prefix ? std::move(step_id_prefix) : identifier_.parent_id, + initial_step), + replaying_(replaying) {} + +durable_context durable_context::fork_at( + std::uint64_t operation_index) const { + return durable_context{ + *state_, identifier_, id_generator_.prefix(), replaying_, + operation_index}; +} + +std::string durable_context::reserve_operation_id( + std::optional local_id) { + return local_id ? id_generator_.reserve(*local_id) + : id_generator_.next(); +} + +operation_identifier durable_context::reserve_operation( + std::string_view sub_type, operation_type type, + std::optional name) { + if (auto reserved = + detail::consume_operation_reservation(*this, type)) { + return std::move(*reserved); + } + return operation_identifier{ + .operation_id = id_generator_.next(), + .sub_type = std::string{sub_type}, + .parent_id = identifier_.parent_id, + .name = name ? std::optional{*name} : std::nullopt, + .type = type, + }; +} + +operation_identifier durable_context::reserve_operation( + std::string_view local_id, std::string_view sub_type, operation_type type, + std::optional name) { + if (auto reserved = + detail::consume_operation_reservation(*this, type)) { + return std::move(*reserved); + } + return operation_identifier{ + .operation_id = id_generator_.reserve(local_id), + .sub_type = std::string{sub_type}, + .parent_id = identifier_.parent_id, + .name = name ? std::optional{*name} : std::nullopt, + .type = type, + }; +} + +void durable_context::before_operation( + std::string_view operation_id, bool executes_user_code) { + if (!replaying_) { + return; + } + const auto existing = state_->find_operation(operation_id); + if (existing && !is_terminal(existing->status)) { + state_->notify_replay_operation(operation_id); + } + if (!existing || (executes_user_code && !is_terminal(existing->status))) { + replaying_ = false; + } +} + +namespace detail { + +scoped_operation_reservation::scoped_operation_reservation( + durable_context& context, operation_identifier identifier, + operation_type expected_type) + : context_(&context), + identifier_(std::move(identifier)), + expected_type_(expected_type), + previous_(active_operation_reservation) { + active_operation_reservation = this; +} + +scoped_operation_reservation::~scoped_operation_reservation() { + if (active_operation_reservation == this) { + active_operation_reservation = previous_; + } +} + +std::optional consume_operation_reservation( + durable_context& context, operation_type type) { + auto* reservation = active_operation_reservation; + if (!reservation) return std::nullopt; + if (reservation->context_ != &context) { + throw invalid_state_error( + "An extension operation was claimed from a different durable context"); + } + if (reservation->consumed_) { + throw invalid_state_error( + "An extension operation reservation was consumed more than once"); + } + if (reservation->expected_type_ != type) { + throw invalid_state_error( + "An extension operation used a different primitive type than claimed"); + } + reservation->consumed_ = true; + active_operation_reservation = reservation->previous_; + return reservation->identifier_; +} + +} // namespace detail + +scoped_context::scoped_context(durable_context& context) noexcept + : previous_(active_context) { + active_context = &context; +} + +scoped_context::~scoped_context() { active_context = previous_; } + +scoped_non_durable_region::scoped_non_durable_region() noexcept + : previous_(durable_operations_allowed) { + durable_operations_allowed = false; +} + +scoped_non_durable_region::~scoped_non_durable_region() { + durable_operations_allowed = previous_; +} + +durable_context& current_context() { + if (!active_context) { + throw invalid_state_error( + "A durable operation was called outside a durable execution"); + } + if (!durable_operations_allowed) { + throw invalid_state_error( + "Durable operations cannot be nested inside a durable step"); + } + return *active_context; +} + +durable_context* try_current_context() noexcept { + return durable_operations_allowed ? active_context : nullptr; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/src/detail/blake2b.cpp b/src/detail/blake2b.cpp new file mode 100644 index 0000000..9ce0c60 --- /dev/null +++ b/src/detail/blake2b.cpp @@ -0,0 +1,150 @@ +#include "aws/durable_execution/detail/blake2b.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace aws::durable_execution { +inline namespace v1 { +namespace detail { +namespace { + +constexpr std::array initialization_vector{ + 0x6a09e667f3bcc908ULL, + 0xbb67ae8584caa73bULL, + 0x3c6ef372fe94f82bULL, + 0xa54ff53a5f1d36f1ULL, + 0x510e527fade682d1ULL, + 0x9b05688c2b3e6c1fULL, + 0x1f83d9abfb41bd6bULL, + 0x5be0cd19137e2179ULL, +}; + +constexpr std::array, 12> sigma{{ + {{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}}, + {{14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}}, + {{11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4}}, + {{7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8}}, + {{9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13}}, + {{2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9}}, + {{12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11}}, + {{13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10}}, + {{6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5}}, + {{10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0}}, + {{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}}, + {{14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}}, +}}; + +[[nodiscard]] std::uint64_t load64(const std::byte* bytes) noexcept { + std::uint64_t value = 0; + std::memcpy(&value, bytes, sizeof(value)); + if constexpr (std::endian::native == std::endian::big) { + value = std::byteswap(value); + } + return value; +} + +void mix( + std::array& value, std::size_t a, std::size_t b, + std::size_t c, std::size_t d, std::uint64_t x, std::uint64_t y) noexcept { + value[a] = value[a] + value[b] + x; + value[d] = std::rotr(value[d] ^ value[a], 32); + value[c] += value[d]; + value[b] = std::rotr(value[b] ^ value[c], 24); + value[a] = value[a] + value[b] + y; + value[d] = std::rotr(value[d] ^ value[a], 16); + value[c] += value[d]; + value[b] = std::rotr(value[b] ^ value[c], 63); +} + +void compress( + std::array& hash, + const std::array& block, std::uint64_t offset_low, + std::uint64_t offset_high, bool final_block) noexcept { + std::array message{}; + for (std::size_t index = 0; index < message.size(); ++index) { + message[index] = load64(block.data() + index * sizeof(std::uint64_t)); + } + + std::array value{}; + std::copy(hash.begin(), hash.end(), value.begin()); + std::copy( + initialization_vector.begin(), initialization_vector.end(), + value.begin() + 8); + value[12] ^= offset_low; + value[13] ^= offset_high; + if (final_block) { + value[14] = ~value[14]; + } + + for (std::size_t round = 0; round < sigma.size(); ++round) { + const auto& order = sigma[round]; + mix(value, 0, 4, 8, 12, message[order[0]], message[order[1]]); + mix(value, 1, 5, 9, 13, message[order[2]], message[order[3]]); + mix(value, 2, 6, 10, 14, message[order[4]], message[order[5]]); + mix(value, 3, 7, 11, 15, message[order[6]], message[order[7]]); + mix(value, 0, 5, 10, 15, message[order[8]], message[order[9]]); + mix(value, 1, 6, 11, 12, message[order[10]], message[order[11]]); + mix(value, 2, 7, 8, 13, message[order[12]], message[order[13]]); + mix(value, 3, 4, 9, 14, message[order[14]], message[order[15]]); + } + + for (std::size_t index = 0; index < hash.size(); ++index) { + hash[index] ^= value[index] ^ value[index + 8]; + } +} + +} // namespace + +std::string blake2b_512_hex_64(std::string_view input) { + std::array hash = initialization_vector; + hash[0] ^= 0x01010040ULL; // fanout=1, depth=1, digest length=64 + + const auto bytes = std::as_bytes(std::span{input.data(), input.size()}); + std::uint64_t offset_low = 0; + std::uint64_t offset_high = 0; + std::size_t position = 0; + + while (bytes.size() - position > 128U) { + std::array block{}; + std::copy_n(bytes.begin() + static_cast(position), 128, block.begin()); + const auto previous = offset_low; + offset_low += 128U; + if (offset_low < previous) { + ++offset_high; + } + compress(hash, block, offset_low, offset_high, false); + position += 128U; + } + + std::array final_block{}; + const std::size_t remaining = bytes.size() - position; + std::copy_n( + bytes.begin() + static_cast(position), + static_cast(remaining), final_block.begin()); + const auto previous = offset_low; + offset_low += static_cast(remaining); + if (offset_low < previous) { + ++offset_high; + } + compress(hash, final_block, offset_low, offset_high, true); + + constexpr char hex[] = "0123456789abcdef"; + std::string output(64, '\0'); + for (std::size_t byte_index = 0; byte_index < 32; ++byte_index) { + const auto word = hash[byte_index / 8U]; + const auto byte = static_cast( + (word >> ((byte_index % 8U) * 8U)) & 0xFFU); + output[byte_index * 2U] = hex[byte >> 4U]; + output[byte_index * 2U + 1U] = hex[byte & 0x0FU]; + } + return output; +} + +} // namespace detail +} // namespace v1 +} // namespace aws::durable_execution diff --git a/src/execution_state.cpp b/src/execution_state.cpp new file mode 100644 index 0000000..3585d65 --- /dev/null +++ b/src/execution_state.cpp @@ -0,0 +1,594 @@ +#include "aws/durable_execution/execution_state.hpp" + +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/error.hpp" +#include "aws/durable_execution/wire.hpp" + +namespace aws::durable_execution { +inline namespace v1 { +namespace { + +[[nodiscard]] bool is_terminal_execution_update( + std::span updates) noexcept { + for (const auto& update : updates) { + if (update.type == operation_type::execution && + (update.action == operation_action::succeed || + update.action == operation_action::fail)) { + return true; + } + } + return false; +} + +[[nodiscard]] bool is_terminal_operation( + const operation_status_value& status) noexcept { + if (!status.is_known()) return false; + switch (*status.known()) { + case operation_status::succeeded: + case operation_status::failed: + case operation_status::cancelled: + case operation_status::timed_out: + case operation_status::stopped: + return true; + case operation_status::started: + case operation_status::pending: + case operation_status::ready: + return false; + } + return false; +} + +[[nodiscard]] std::size_t optional_string_size( + const std::optional& value) noexcept { + return value ? value->size() : 0U; +} + +} // namespace + +execution_state::checkpoint_batching_scope::checkpoint_batching_scope( + execution_state& state) noexcept + : state_(&state) { + state_->checkpoint_batching_scopes_.fetch_add( + 1U, std::memory_order_acq_rel); +} + +execution_state::checkpoint_batching_scope::~checkpoint_batching_scope() { + if (state_) { + state_->release_checkpoint_batching(); + } +} + +execution_state::checkpoint_batching_scope::checkpoint_batching_scope( + checkpoint_batching_scope&& other) noexcept + : state_(std::exchange(other.state_, nullptr)) {} + +execution_state::checkpoint_batching_scope& +execution_state::checkpoint_batching_scope::operator=( + checkpoint_batching_scope&& other) noexcept { + if (this != &other) { + if (state_) { + state_->release_checkpoint_batching(); + } + state_ = std::exchange(other.state_, nullptr); + } + return *this; +} + +execution_state::execution_state( + std::string durable_execution_arn, std::string checkpoint_token, + service_client& client, checkpoint_batcher_config batcher_config, + lambda_invocation_metadata invocation_metadata) + : execution_state( + std::move(durable_execution_arn), + std::move(checkpoint_token), client, batcher_config, + std::move(invocation_metadata), nullptr) {} + +execution_state::execution_state( + std::string durable_execution_arn, std::string checkpoint_token, + service_client& client, checkpoint_batcher_config batcher_config, + lambda_invocation_metadata invocation_metadata, + detail::plugin_manager* plugins) + : durable_execution_arn_(std::move(durable_execution_arn)), + checkpoint_token_(std::move(checkpoint_token)), + invocation_metadata_(std::move(invocation_metadata)), + client_(&client), + batcher_config_(batcher_config), + plugins_(plugins) { + if (batcher_config_.max_batch_operations == 0U) { + throw std::invalid_argument( + "checkpoint max_batch_operations must be positive"); + } + if (batcher_config_.max_batch_size_bytes == 0U) { + throw std::invalid_argument( + "checkpoint max_batch_size_bytes must be positive"); + } +} + +void execution_state::initialize( + const initial_execution_state& initial_state) { + std::lock_guard checkpoint_lock{checkpoint_call_mutex_}; + { + std::unique_lock operations_lock{operations_mutex_}; + operations_.reserve(initial_state.operations.size() + 16U); + } + load_operations(initial_state.operations); + fetch_remaining_locked(initial_state.next_marker); + + const auto root = execution_operation(); + if (root && root->execution && root->execution->input_payload) { + input_payload_ = *root->execution->input_payload; + } else { + input_payload_.clear(); + } + recursive_level_ = 0; + if (const auto level = + json_object_integer_field(input_payload_, "__recursive_level"); + level && *level && **level >= 0 && + static_cast(**level) <= + std::numeric_limits::max()) { + recursive_level_ = static_cast(**level); + } +} + +void execution_state::load_operations( + std::span operations) { + std::unique_lock lock{operations_mutex_}; + const auto observed_at = std::chrono::system_clock::now(); + for (const auto& value : operations) { + operation normalized = value; + const auto existing = operations_.find(value.operation_id); + if (!normalized.start_timestamp) { + normalized.start_timestamp = + existing != operations_.end() && + existing->second->start_timestamp + ? existing->second->start_timestamp + : std::optional{observed_at}; + } + if (!normalized.end_timestamp && + is_terminal_operation(normalized.status)) { + normalized.end_timestamp = observed_at; + } + operations_.insert_or_assign( + value.operation_id, + std::make_shared(std::move(normalized))); + } +} + +void execution_state::fetch_remaining_locked( + std::optional marker) { + while (marker && !marker->empty()) { + auto output = client_->get_execution_state(get_state_request{ + .durable_execution_arn = durable_execution_arn_, + .checkpoint_token = checkpoint_token_, + .marker = *marker, + }); + if (!output) { + throw state_fetch_error( + std::move(output.error().message), output.error().retryable); + } + load_operations(output->operations); + marker = std::move(output->next_marker); + } +} + +std::string execution_state::checkpoint_token() const { + std::lock_guard lock{checkpoint_call_mutex_}; + return checkpoint_token_; +} + +execution_state::operation_snapshot execution_state::find_operation( + std::string_view id) const { + std::shared_lock lock{operations_mutex_}; + const auto found = operations_.find(id); + return found == operations_.end() ? nullptr : found->second; +} + +execution_state::operation_snapshot execution_state::execution_operation() + const { + const auto slash = durable_execution_arn_.find_last_of('/'); + const std::string_view invocation_id = + slash == std::string::npos + ? std::string_view{durable_execution_arn_} + : std::string_view{durable_execution_arn_}.substr(slash + 1U); + auto root = find_operation(invocation_id); + if (root && root->type != operation_type::execution) { + throw invalid_state_error( + "The root durable operation does not have EXECUTION type"); + } + return root; +} + +bool execution_state::has_prior_operations() const { + std::shared_lock lock{operations_mutex_}; + for (const auto& [id, value] : operations_) { + (void)id; + if (value->type != operation_type::execution) { + return true; + } + } + return false; +} + +std::string_view execution_state::input_payload() const noexcept { + return input_payload_; +} + +std::size_t execution_state::operation_count() const { + std::shared_lock lock{operations_mutex_}; + return operations_.size(); +} + +std::vector +execution_state::operation_snapshots() const { + std::vector result; + { + std::shared_lock lock{operations_mutex_}; + result.reserve(operations_.size()); + for (const auto& [id, value] : operations_) { + (void)id; + result.push_back(value); + } + } + std::ranges::sort( + result, {}, [](const operation_snapshot& value) { + return value->operation_id; + }); + return result; +} + +std::vector +execution_state::operation_snapshots( + std::span operation_ids) const { + std::vector result; + result.reserve(operation_ids.size()); + std::shared_lock lock{operations_mutex_}; + for (const auto& id : operation_ids) { + const auto found = operations_.find(id); + if (found != operations_.end()) { + result.push_back(found->second); + } + } + return result; +} + +void execution_state::notify_operation_start( + const operation_snapshot& value, bool is_replay, + bool is_replaying_children) noexcept { + if (!plugins_ || !plugins_->enabled() || !value) return; + bool should_notify = false; + { + std::lock_guard lock{plugin_notification_mutex_}; + should_notify = + operation_start_notifications_.emplace(value->operation_id).second; + } + if (should_notify) { + plugins_->operation_start( + durable_execution_arn_, value, is_replay, + is_replaying_children); + } +} + +void execution_state::notify_operation_end( + const operation_snapshot& value, bool is_replay) noexcept { + if (!plugins_ || !plugins_->enabled() || !value || + !is_terminal_operation(value->status)) { + return; + } + bool should_notify = false; + { + std::lock_guard lock{plugin_notification_mutex_}; + should_notify = + operation_end_notifications_.emplace(value->operation_id).second; + } + if (should_notify) { + plugins_->operation_end(durable_execution_arn_, value, is_replay); + } +} + +void execution_state::notify_replay_operation( + std::string_view operation_id, bool is_replaying_children) noexcept { + if (!plugins_ || !plugins_->enabled()) return; + const auto value = find_operation(operation_id); + if (!value || is_terminal_operation(value->status)) return; + notify_operation_start(value, true, is_replaying_children); +} + +void execution_state::notify_attempt_start( + std::string_view operation_id, std::uint32_t attempt, + timestamp started, bool is_replay, + bool is_replaying_children) noexcept { + if (!plugins_ || !plugins_->enabled()) return; + const auto value = find_operation(operation_id); + if (!value) return; + plugins_->attempt_start( + durable_execution_arn_, value, attempt, started, is_replay, + is_replaying_children); +} + +void execution_state::notify_attempt_end( + std::string_view operation_id, std::uint32_t attempt, + timestamp started, timestamp ended, bool succeeded, + const error_object* error, bool is_replay, + bool is_replaying_children) noexcept { + if (!plugins_ || !plugins_->enabled()) return; + const auto value = find_operation(operation_id); + if (!value) return; + plugins_->attempt_end( + durable_execution_arn_, value, attempt, started, ended, succeeded, + error, is_replay, is_replaying_children); +} + +void execution_state::notify_external_updates( + std::span operation_ids) noexcept { + if (!plugins_ || !plugins_->enabled() || operation_ids.empty()) return; + const auto updated = operation_snapshots(operation_ids); + const auto all = operation_snapshots(); + plugins_->operation_change( + durable_execution_arn_, updated, all, false); + for (const auto& value : updated) { + notify_operation_end(value, false); + } +} + +execution_state::operation_snapshot execution_state::checkpoint( + const operation_update& update) { + const std::array updates{update}; + checkpoint(updates); + return find_operation(update.operation_id); +} + +void execution_state::checkpoint( + std::span updates) { + if (checkpoint_batching_scopes_.load(std::memory_order_acquire) != 0U) { + checkpoint_batched(updates); + return; + } + checkpoint_direct(updates); +} + +execution_state::checkpoint_batching_scope +execution_state::enable_checkpoint_batching() noexcept { + return checkpoint_batching_scope{*this}; +} + +void execution_state::checkpoint_direct( + std::span updates) { + std::unique_lock checkpoint_lock{checkpoint_call_mutex_}; + std::vector existed; + if (plugins_ && plugins_->enabled()) { + existed.reserve(updates.size()); + for (const auto& update : updates) { + existed.push_back( + !update.operation_id.empty() && + static_cast(find_operation(update.operation_id))); + } + } + + auto output = client_->checkpoint(checkpoint_request{ + .durable_execution_arn = durable_execution_arn_, + .checkpoint_token = checkpoint_token_, + .updates = updates, + .client_token = std::nullopt, + }); + if (!output) { + throw checkpoint_error( + std::move(output.error().message), output.error().retryable); + } + + if (output->checkpoint_token) { + checkpoint_token_ = std::move(*output->checkpoint_token); + } else if (!is_terminal_execution_update(updates)) { + throw checkpoint_error( + "Checkpoint response omitted the token before execution completion", + false); + } + + load_operations(output->operations); + fetch_remaining_locked(std::move(output->next_marker)); + checkpoint_lock.unlock(); + + if (!plugins_ || !plugins_->enabled()) return; + + std::vector updated; + updated.reserve(updates.size()); + for (const auto& update : updates) { + if (update.operation_id.empty()) continue; + if (const auto value = find_operation(update.operation_id)) { + updated.push_back(value); + } + } + const auto all = operation_snapshots(); + plugins_->operation_change( + durable_execution_arn_, updated, all, false); + + for (std::size_t index = 0; index < updates.size(); ++index) { + const auto& update = updates[index]; + if (update.operation_id.empty()) continue; + const auto value = find_operation(update.operation_id); + if (!value) continue; + if (update.action == operation_action::start) { + notify_operation_start( + value, index < existed.size() && existed[index]); + } else if ( + update.action == operation_action::succeed || + update.action == operation_action::fail || + update.action == operation_action::cancel) { + notify_operation_end(value, false); + } + } +} + +void execution_state::checkpoint_batched( + std::span updates) { + auto request = std::make_shared(); + request->updates.assign(updates.begin(), updates.end()); + + bool is_processor = false; + { + std::unique_lock lock{batch_queue_mutex_}; + if (batch_failure_) { + const auto failure = batch_failure_; + lock.unlock(); + std::rethrow_exception(failure); + } + + pending_checkpoints_.push_back(request); + if (!batch_processor_active_) { + batch_processor_active_ = true; + is_processor = true; + } + + if (!is_processor) { + request->completed_condition.wait( + lock, [&] { return request->completed; }); + const auto failure = request->error; + lock.unlock(); + if (failure) { + std::rethrow_exception(failure); + } + return; + } + } + + process_pending_batches(); + + std::unique_lock lock{batch_queue_mutex_}; + request->completed_condition.wait( + lock, [&] { return request->completed; }); + const auto failure = request->error; + lock.unlock(); + if (failure) { + std::rethrow_exception(failure); + } +} + +void execution_state::process_pending_batches() { + while (true) { + if (batcher_config_.coalescing_delay > std::chrono::microseconds::zero()) { + std::this_thread::sleep_for(batcher_config_.coalescing_delay); + } else { + for (std::uint32_t count = 0; + count < batcher_config_.coalescing_yields; ++count) { + std::this_thread::yield(); + } + } + + std::vector> requests; + std::vector updates; + { + std::unique_lock lock{batch_queue_mutex_}; + if (pending_checkpoints_.empty()) { + batch_processor_active_ = false; + return; + } + + std::size_t total_size = 0; + std::size_t total_operations = 0; + while (!pending_checkpoints_.empty()) { + const auto& candidate = pending_checkpoints_.front(); + std::size_t candidate_size = 0; + for (const auto& update : candidate->updates) { + candidate_size += estimate_update_size(update); + } + const std::size_t candidate_operations = + candidate->updates.size(); + const bool batch_has_items = !requests.empty(); + if (batch_has_items && + (total_operations + candidate_operations > + batcher_config_.max_batch_operations || + total_size + candidate_size > + batcher_config_.max_batch_size_bytes)) { + break; + } + + auto selected = std::move(pending_checkpoints_.front()); + pending_checkpoints_.pop_front(); + total_size += candidate_size; + total_operations += candidate_operations; + updates.insert( + updates.end(), selected->updates.begin(), + selected->updates.end()); + requests.push_back(std::move(selected)); + } + } + + try { + checkpoint_direct(updates); + } catch (...) { + const auto failure = std::current_exception(); + std::vector> failed_requests; + { + std::lock_guard lock{batch_queue_mutex_}; + batch_failure_ = failure; + failed_requests = requests; + while (!pending_checkpoints_.empty()) { + failed_requests.push_back( + std::move(pending_checkpoints_.front())); + pending_checkpoints_.pop_front(); + } + for (const auto& request : failed_requests) { + request->error = failure; + request->completed = true; + } + batch_processor_active_ = false; + } + for (const auto& request : failed_requests) { + request->completed_condition.notify_all(); + } + return; + } + + bool queue_empty = false; + { + std::lock_guard lock{batch_queue_mutex_}; + for (const auto& request : requests) { + request->completed = true; + } + queue_empty = pending_checkpoints_.empty(); + if (queue_empty) { + batch_processor_active_ = false; + } + } + for (const auto& request : requests) { + request->completed_condition.notify_all(); + } + if (queue_empty) { + return; + } + } +} + +void execution_state::release_checkpoint_batching() noexcept { + checkpoint_batching_scopes_.fetch_sub(1U, std::memory_order_acq_rel); +} + +std::size_t execution_state::estimate_update_size( + const operation_update& update) const noexcept { + std::size_t size = 128U + update.operation_id.size(); + size += optional_string_size(update.parent_id); + size += optional_string_size(update.name); + size += optional_string_size(update.sub_type); + size += optional_string_size(update.payload); + if (update.error) { + size += optional_string_size(update.error->message); + size += optional_string_size(update.error->type); + size += optional_string_size(update.error->data); + for (const auto& frame : update.error->stack_trace) { + size += frame.size(); + } + } + if (update.chained_invoke) { + size += update.chained_invoke->function_name.size(); + size += optional_string_size(update.chained_invoke->tenant_id); + } + return size; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/src/local_runner.cpp b/src/local_runner.cpp new file mode 100644 index 0000000..56758d7 --- /dev/null +++ b/src/local_runner.cpp @@ -0,0 +1,947 @@ +#include "aws/durable_execution/local_runner.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace aws::durable_execution { +inline namespace v1 { +namespace { + +[[nodiscard]] std::string token_for(std::uint64_t sequence) { + return "local-token-" + std::to_string(sequence); +} + +[[nodiscard]] error_object local_error( + std::string message, std::string type) { + return error_object{ + .message = std::move(message), + .type = std::move(type), + .data = std::nullopt, + .stack_trace = {}, + }; +} + +} // namespace + +struct local_service_client::impl { + struct callback_timer { + std::optional deadline; + std::optional heartbeat_deadline; + std::chrono::seconds heartbeat_interval{0}; + }; + + struct invoke_request { + std::string function_name; + std::string payload; + }; + + struct invoke_mock { + bool succeeded{true}; + std::string result; + error_object error; + }; + + explicit impl(local_runner_options runner_options) + : options(std::move(runner_options)), + now(options.virtual_start_time), + execution_arn( + "arn:aws:lambda:local:000000000000:function:" + + options.function_name + "/" + options.execution_name), + metadata{ + .function_name = options.function_name, + .function_version = options.function_version, + .invoked_function_arn = + "arn:aws:lambda:local:000000000000:function:" + + options.function_name + ":" + options.function_version, + .tenant_id = options.tenant_id, + } { + if (options.execution_name.empty()) { + throw std::invalid_argument( + "local execution name must not be empty"); + } + if (options.max_invocations == 0U) { + throw std::invalid_argument( + "local max_invocations must be positive"); + } + current_token = token_for(token_sequence); + if (options.execution_timeout > std::chrono::seconds::zero()) { + execution_deadline = now + options.execution_timeout; + } + + const std::string root_id = options.execution_name; + operation root; + root.operation_id = root_id; + root.type = operation_type::execution; + root.status = operation_status::started; + root.name = options.execution_name; + root.start_timestamp = now; + root.sub_type = std::string{operation_subtype::execution}; + root.execution = execution_details{ + .input_payload = options.input_json, + }; + operation_indices.emplace(root.operation_id, 0U); + operations.push_back(std::move(root)); + } + + [[nodiscard]] std::size_t require_operation_index( + std::string_view operation_id) const { + const auto found = operation_indices.find( + std::string{operation_id}); + if (found == operation_indices.end()) { + throw std::runtime_error( + "Local operation does not exist: " + + std::string{operation_id}); + } + return found->second; + } + + [[nodiscard]] operation& require_operation( + std::string_view operation_id) { + return operations[require_operation_index(operation_id)]; + } + + [[nodiscard]] const operation& require_operation( + std::string_view operation_id) const { + return operations[require_operation_index(operation_id)]; + } + + [[nodiscard]] operation& operation_for_update( + const operation_update& update) { + const auto found = operation_indices.find(update.operation_id); + if (found != operation_indices.end()) { + auto& value = operations[found->second]; + if (value.type != update.type || + value.parent_id != update.parent_id || + value.name != update.name || + value.sub_type != update.sub_type) { + throw std::runtime_error( + "Local checkpoint identity changed for operation " + + update.operation_id); + } + return value; + } + + operation value; + value.operation_id = update.operation_id; + value.type = update.type; + value.status = operation_status::started; + value.parent_id = update.parent_id; + value.name = update.name; + value.start_timestamp = now; + value.sub_type = update.sub_type; + const std::size_t index = operations.size(); + operation_indices.emplace(value.operation_id, index); + operations.push_back(std::move(value)); + return operations.back(); + } + + void mark_external_update(std::string_view operation_id) { + if (std::ranges::find( + updated_operation_ids, operation_id) == + updated_operation_ids.end()) { + updated_operation_ids.emplace_back(operation_id); + } + } + + void apply_start( + operation& value, const operation_update& update) { + if (value.status != operation_status::started && + value.status != operation_status::ready) { + throw std::runtime_error( + "Cannot start a terminal local operation"); + } + value.status = operation_status::started; + value.end_timestamp.reset(); + switch (update.type) { + case operation_type::step: + if (!value.step) value.step = step_details{}; + value.step->next_attempt_timestamp.reset(); + break; + case operation_type::wait: { + const auto seconds = + update.wait ? update.wait->wait_seconds : 1U; + value.wait = wait_details{ + .scheduled_end_timestamp = + now + std::chrono::seconds{seconds}, + }; + break; + } + case operation_type::callback: { + const std::string callback_id = + "local-callback:" + value.operation_id; + value.callback = callback_details{ + .callback_id = callback_id, + .result = std::nullopt, + .error = std::nullopt, + }; + callback_timer timer; + if (update.callback && + update.callback->timeout_seconds != 0U) { + timer.deadline = + now + std::chrono::seconds{ + update.callback->timeout_seconds}; + } + if (update.callback && + update.callback->heartbeat_timeout_seconds != 0U) { + timer.heartbeat_interval = std::chrono::seconds{ + update.callback->heartbeat_timeout_seconds}; + timer.heartbeat_deadline = + now + timer.heartbeat_interval; + } + callback_timers.insert_or_assign( + callback_id, std::move(timer)); + break; + } + case operation_type::chained_invoke: + value.chained_invoke = chained_invoke_details{}; + if (!update.chained_invoke) { + throw std::runtime_error( + "Local chained invoke is missing options"); + } + invoke_requests.insert_or_assign( + value.operation_id, + invoke_request{ + .function_name = + update.chained_invoke->function_name, + .payload = update.payload.value_or("null"), + }); + break; + case operation_type::context: + if (!value.context) value.context = context_details{}; + break; + case operation_type::execution: + break; + } + } + + void apply_succeed( + operation& value, const operation_update& update) { + value.status = operation_status::succeeded; + value.end_timestamp = now; + switch (update.type) { + case operation_type::step: + if (!value.step) value.step = step_details{}; + ++value.step->attempt; + value.step->result = update.payload; + value.step->error.reset(); + value.step->next_attempt_timestamp.reset(); + break; + case operation_type::context: + if (!value.context) value.context = context_details{}; + value.context->result = update.payload; + value.context->error.reset(); + value.context->replay_children = + update.context && update.context->replay_children; + break; + default: break; + } + } + + void apply_fail( + operation& value, const operation_update& update) { + value.status = operation_status::failed; + value.end_timestamp = now; + switch (update.type) { + case operation_type::step: + if (!value.step) value.step = step_details{}; + ++value.step->attempt; + value.step->error = update.error; + value.step->next_attempt_timestamp.reset(); + break; + case operation_type::context: + if (!value.context) value.context = context_details{}; + value.context->error = update.error; + break; + default: break; + } + } + + void apply_update(const operation_update& update) { + update_history.push_back(update); + if (update.type == operation_type::execution) { + auto& root = operations.front(); + root.end_timestamp = now; + if (update.action == operation_action::succeed) { + root.status = operation_status::succeeded; + terminal = invocation_output{ + .status = invocation_status::succeeded, + .result = update.payload, + .error = std::nullopt, + }; + } else if (update.action == operation_action::fail) { + root.status = operation_status::failed; + terminal = invocation_output{ + .status = invocation_status::failed, + .result = std::nullopt, + .error = update.error, + }; + } else { + throw std::runtime_error( + "Unsupported local execution update action"); + } + return; + } + + auto& value = operation_for_update(update); + switch (update.action) { + case operation_action::start: + apply_start(value, update); + break; + case operation_action::succeed: + apply_succeed(value, update); + break; + case operation_action::fail: + apply_fail(value, update); + break; + case operation_action::retry: { + if (update.type != operation_type::step) { + throw std::runtime_error( + "Only STEP can use RETRY locally"); + } + value.status = operation_status::pending; + value.end_timestamp.reset(); + if (!value.step) value.step = step_details{}; + ++value.step->attempt; + value.step->result = update.payload; + value.step->error = update.error; + const auto seconds = + update.step + ? update.step->next_attempt_delay_seconds + : 1U; + value.step->next_attempt_timestamp = + now + std::chrono::seconds{seconds}; + break; + } + case operation_action::cancel: + value.status = operation_status::cancelled; + value.end_timestamp = now; + break; + } + } + + void increment_token() { + ++token_sequence; + current_token = token_for(token_sequence); + } + + [[nodiscard]] std::optional next_event_time( + bool include_callback_timeouts) const { + std::optional next = + include_callback_timeouts ? execution_deadline : std::nullopt; + const auto consider = [&](std::optional candidate) { + if (candidate && (!next || *candidate < *next)) { + next = candidate; + } + }; + for (const auto& value : operations) { + if (value.type == operation_type::wait && + value.status == operation_status::started && value.wait) { + consider(value.wait->scheduled_end_timestamp); + } else if ( + value.type == operation_type::step && + value.status == operation_status::pending && value.step) { + consider(value.step->next_attempt_timestamp); + } else if ( + value.type == operation_type::chained_invoke && + value.status == operation_status::started) { + const auto request = invoke_requests.find(value.operation_id); + if (request != invoke_requests.end() && + invoke_mocks.contains(request->second.function_name)) { + consider(now); + } + } + } + if (include_callback_timeouts) { + for (const auto& [callback_id, timer] : callback_timers) { + (void)callback_id; + consider(timer.deadline); + consider(timer.heartbeat_deadline); + } + } + if (!include_callback_timeouts && next && execution_deadline && + *execution_deadline < *next) { + next = execution_deadline; + } + return next; + } + + [[nodiscard]] bool process_due( + timestamp target, bool include_callback_timeouts) { + if (target > now) now = target; + if (execution_deadline && now >= *execution_deadline) { + terminal = invocation_output{ + .status = invocation_status::failed, + .result = std::nullopt, + .error = local_error( + "Local execution timed out", "LocalExecutionTimeout"), + }; + operations.front().status = operation_status::timed_out; + operations.front().end_timestamp = now; + return true; + } + + bool changed = false; + for (auto& value : operations) { + if (value.type == operation_type::wait && + value.status == operation_status::started && value.wait && + value.wait->scheduled_end_timestamp && + *value.wait->scheduled_end_timestamp <= now) { + value.status = operation_status::succeeded; + value.end_timestamp = now; + mark_external_update(value.operation_id); + changed = true; + } else if ( + value.type == operation_type::step && + value.status == operation_status::pending && value.step && + value.step->next_attempt_timestamp && + *value.step->next_attempt_timestamp <= now) { + value.status = operation_status::ready; + value.step->next_attempt_timestamp.reset(); + mark_external_update(value.operation_id); + changed = true; + } else if ( + value.type == operation_type::chained_invoke && + value.status == operation_status::started) { + const auto request = invoke_requests.find(value.operation_id); + if (request == invoke_requests.end()) continue; + const auto mock = + invoke_mocks.find(request->second.function_name); + if (mock == invoke_mocks.end()) continue; + if (!value.chained_invoke) { + value.chained_invoke = chained_invoke_details{}; + } + value.end_timestamp = now; + if (mock->second.succeeded) { + value.status = operation_status::succeeded; + value.chained_invoke->result = mock->second.result; + } else { + value.status = operation_status::failed; + value.chained_invoke->error = mock->second.error; + } + mark_external_update(value.operation_id); + changed = true; + } + } + + if (include_callback_timeouts) { + for (auto iterator = callback_timers.begin(); + iterator != callback_timers.end();) { + const auto callback_index = std::ranges::find_if( + operations, [&](const operation& value) { + return value.type == operation_type::callback && + value.callback && + value.callback->callback_id == iterator->first; + }); + if (callback_index == operations.end() || + callback_index->status != operation_status::started) { + iterator = callback_timers.erase(iterator); + continue; + } + + const bool heartbeat_timeout = + iterator->second.heartbeat_deadline && + *iterator->second.heartbeat_deadline <= now; + const bool overall_timeout = + iterator->second.deadline && + *iterator->second.deadline <= now; + if (!heartbeat_timeout && !overall_timeout) { + ++iterator; + continue; + } + callback_index->status = operation_status::timed_out; + callback_index->end_timestamp = now; + callback_index->callback->error = local_error( + heartbeat_timeout ? "Callback heartbeat timed out" + : "Callback timed out", + heartbeat_timeout ? "Callback.Heartbeat" + : "Callback.Timeout"); + mark_external_update(callback_index->operation_id); + iterator = callback_timers.erase(iterator); + changed = true; + } + } + if (changed) increment_token(); + return changed; + } + + mutable std::mutex mutex; + local_runner_options options; + timestamp now; + std::optional execution_deadline; + std::string execution_arn; + lambda_invocation_metadata metadata; + std::uint64_t token_sequence{0}; + std::string current_token; + std::vector operations; + std::unordered_map operation_indices; + std::vector update_history; + std::vector updated_operation_ids; + std::unordered_map callback_timers; + std::unordered_map invoke_requests; + std::unordered_map invoke_mocks; + std::optional terminal; +}; + +local_service_client::local_service_client(local_runner_options options) + : impl_(std::make_unique(std::move(options))) {} + +local_service_client::~local_service_client() = default; +local_service_client::local_service_client(local_service_client&&) noexcept = + default; +local_service_client& local_service_client::operator=( + local_service_client&&) noexcept = default; + +std::expected +local_service_client::checkpoint(const checkpoint_request& request) { + std::lock_guard lock{impl_->mutex}; + if (request.durable_execution_arn != impl_->execution_arn) { + return std::unexpected(service_error{ + .message = "Local durable execution ARN does not match", + .retryable = false, + }); + } + if (request.checkpoint_token != impl_->current_token || + impl_->terminal) { + return std::unexpected(service_error{ + .message = "Invalid or consumed local checkpoint token", + .retryable = false, + }); + } + try { + for (const auto& update : request.updates) { + impl_->apply_update(update); + } + } catch (const std::exception& error) { + return std::unexpected(service_error{ + .message = error.what(), + .retryable = false, + }); + } + + checkpoint_output output; + output.operations = impl_->operations; + if (!impl_->terminal) { + impl_->increment_token(); + output.checkpoint_token = impl_->current_token; + } + return output; +} + +std::expected +local_service_client::get_execution_state( + const get_state_request& request) { + std::lock_guard lock{impl_->mutex}; + if (request.durable_execution_arn != impl_->execution_arn || + request.checkpoint_token != impl_->current_token) { + return std::unexpected(service_error{ + .message = "Invalid local state request", + .retryable = false, + }); + } + std::size_t offset = 0; + if (!request.marker.empty()) { + const auto [end, error] = std::from_chars( + request.marker.data(), + request.marker.data() + request.marker.size(), offset); + if (error != std::errc{} || + end != request.marker.data() + request.marker.size()) { + return std::unexpected(service_error{ + .message = "Invalid local pagination marker", + .retryable = false, + }); + } + } + const std::size_t limit = + std::min(request.max_items, 1'000U); + const std::size_t end = + std::min(impl_->operations.size(), offset + limit); + state_output output; + if (offset < impl_->operations.size()) { + output.operations.insert( + output.operations.end(), + impl_->operations.begin() + static_cast(offset), + impl_->operations.begin() + static_cast(end)); + } + if (end < impl_->operations.size()) { + output.next_marker = std::to_string(end); + } + return output; +} + +invocation_input local_service_client::invocation() { + std::lock_guard lock{impl_->mutex}; + invocation_input result{ + .durable_execution_arn = impl_->execution_arn, + .checkpoint_token = impl_->current_token, + .initial_state = initial_execution_state{ + .operations = impl_->operations, + .next_marker = std::nullopt, + }, + .updated_operation_ids = impl_->updated_operation_ids, + }; + impl_->updated_operation_ids.clear(); + return result; +} + +const lambda_invocation_metadata& +local_service_client::invocation_metadata() const noexcept { + return impl_->metadata; +} + +std::string local_service_client::execution_arn() const { + std::lock_guard lock{impl_->mutex}; + return impl_->execution_arn; +} + +timestamp local_service_client::virtual_time() const { + std::lock_guard lock{impl_->mutex}; + return impl_->now; +} + +std::vector local_service_client::operations() const { + std::lock_guard lock{impl_->mutex}; + return impl_->operations; +} + +std::vector local_service_client::updates() const { + std::lock_guard lock{impl_->mutex}; + return impl_->update_history; +} + +std::vector +local_service_client::pending_callback_ids() const { + std::lock_guard lock{impl_->mutex}; + std::vector callbacks; + for (const auto& value : impl_->operations) { + if (value.type == operation_type::callback && + value.status == operation_status::started && value.callback) { + callbacks.push_back(value.callback->callback_id); + } + } + return callbacks; +} + +bool local_service_client::has_external_work() const { + std::lock_guard lock{impl_->mutex}; + for (const auto& value : impl_->operations) { + if (value.type == operation_type::callback && + value.status == operation_status::started) { + return true; + } + if (value.type == operation_type::chained_invoke && + value.status == operation_status::started) { + const auto request = + impl_->invoke_requests.find(value.operation_id); + if (request != impl_->invoke_requests.end() && + !impl_->invoke_mocks.contains(request->second.function_name)) { + return true; + } + } + } + return false; +} + +void local_service_client::complete_invocation( + const invocation_output& output) { + std::lock_guard lock{impl_->mutex}; + if (impl_->terminal) return; + if (output.status != invocation_status::succeeded && + output.status != invocation_status::failed) { + return; + } + impl_->terminal = output; + auto& root = impl_->operations.front(); + root.status = + output.status == invocation_status::succeeded + ? operation_status::succeeded + : operation_status::failed; + root.end_timestamp = impl_->now; +} + +bool local_service_client::advance_next_automatic_event( + bool include_callback_timeouts) { + std::lock_guard lock{impl_->mutex}; + if (impl_->terminal) return false; + const auto next = + impl_->next_event_time(include_callback_timeouts); + if (!next) return false; + return impl_->process_due(*next, include_callback_timeouts); +} + +void local_service_client::advance_time( + std::chrono::seconds duration) { + if (duration < std::chrono::seconds::zero()) { + throw std::invalid_argument( + "Local virtual time cannot move backwards"); + } + std::lock_guard lock{impl_->mutex}; + const timestamp target = impl_->now + duration; + while (!impl_->terminal) { + const auto next = impl_->next_event_time(true); + if (!next || *next > target) break; + (void)impl_->process_due(*next, true); + } + if (!impl_->terminal && impl_->now < target) { + impl_->now = target; + } +} + +void local_service_client::send_callback_success( + std::string_view callback_id, + std::optional serialized_result) { + std::lock_guard lock{impl_->mutex}; + auto found = std::ranges::find_if( + impl_->operations, [&](const operation& value) { + return value.type == operation_type::callback && + value.callback && + value.callback->callback_id == callback_id; + }); + if (found == impl_->operations.end() || + found->status != operation_status::started) { + throw std::invalid_argument( + "Active local callback was not found"); + } + found->status = operation_status::succeeded; + found->end_timestamp = impl_->now; + found->callback->result = std::move(serialized_result); + found->callback->error.reset(); + impl_->callback_timers.erase(std::string{callback_id}); + impl_->mark_external_update(found->operation_id); + impl_->increment_token(); +} + +void local_service_client::send_callback_failure( + std::string_view callback_id, error_object error) { + std::lock_guard lock{impl_->mutex}; + auto found = std::ranges::find_if( + impl_->operations, [&](const operation& value) { + return value.type == operation_type::callback && + value.callback && + value.callback->callback_id == callback_id; + }); + if (found == impl_->operations.end() || + found->status != operation_status::started) { + throw std::invalid_argument( + "Active local callback was not found"); + } + found->status = operation_status::failed; + found->end_timestamp = impl_->now; + found->callback->error = std::move(error); + impl_->callback_timers.erase(std::string{callback_id}); + impl_->mark_external_update(found->operation_id); + impl_->increment_token(); +} + +void local_service_client::send_callback_heartbeat( + std::string_view callback_id) { + std::lock_guard lock{impl_->mutex}; + const auto found = + impl_->callback_timers.find(std::string{callback_id}); + if (found == impl_->callback_timers.end()) { + throw std::invalid_argument( + "Active local callback was not found"); + } + if (found->second.heartbeat_interval > + std::chrono::seconds::zero()) { + found->second.heartbeat_deadline = + impl_->now + found->second.heartbeat_interval; + } +} + +void local_service_client::mock_invoke_success( + std::string function_name, std::string serialized_result) { + std::lock_guard lock{impl_->mutex}; + impl_->invoke_mocks.insert_or_assign( + std::move(function_name), + impl::invoke_mock{ + .succeeded = true, + .result = std::move(serialized_result), + .error = {}, + }); +} + +void local_service_client::mock_invoke_failure( + std::string function_name, error_object error) { + std::lock_guard lock{impl_->mutex}; + impl_->invoke_mocks.insert_or_assign( + std::move(function_name), + impl::invoke_mock{ + .succeeded = false, + .result = {}, + .error = std::move(error), + }); +} + +std::optional +local_service_client::terminal_output() const { + std::lock_guard lock{impl_->mutex}; + return impl_->terminal; +} + +const operation* local_test_result::operation_by_id( + std::string_view operation_id) const noexcept { + const auto found = std::ranges::find_if( + operations_, [&](const operation& value) { + return value.operation_id == operation_id; + }); + return found == operations_.end() ? nullptr : &*found; +} + +const operation* local_test_result::operation_by_name( + std::string_view name) const noexcept { + const auto found = std::ranges::find_if( + operations_, [&](const operation& value) { + return value.name && *value.name == name; + }); + return found == operations_.end() ? nullptr : &*found; +} + +const operation* local_test_result::step( + std::string_view name) const noexcept { + const auto* value = operation_by_name(name); + return value && value->type == operation_type::step ? value : nullptr; +} + +const operation* local_test_result::wait( + std::string_view name) const noexcept { + const auto* value = operation_by_name(name); + return value && value->type == operation_type::wait ? value : nullptr; +} + +std::vector +local_test_result::pending_callback_ids() const { + std::vector callbacks; + for (const auto& value : operations_) { + if (value.type == operation_type::callback && + value.status == operation_status::started && value.callback) { + callbacks.push_back(value.callback->callback_id); + } + } + return callbacks; +} + +local_runner::local_runner( + handler_type handler, local_runner_options options) + : handler_(std::move(handler)), + options_(std::move(options)), + service_(options_) { + if (!handler_) { + throw std::invalid_argument( + "Local runner handler must not be empty"); + } +} + +local_test_result local_runner::run() { + while (invocation_count_ < options_.max_invocations) { + if (const auto terminal = service_.terminal_output()) { + const auto operations = service_.operations(); + const bool timed_out = + !operations.empty() && + operations.front().status == operation_status::timed_out; + return snapshot( + timed_out ? local_run_status::timed_out + : (terminal->status == invocation_status::succeeded + ? local_run_status::succeeded + : local_run_status::failed), + *terminal); + } + + const auto input = service_.invocation(); + const auto output = handler_( + input, service_, service_.invocation_metadata()); + ++invocation_count_; + + if (output.status == invocation_status::succeeded || + output.status == invocation_status::failed) { + service_.complete_invocation(output); + const auto terminal = service_.terminal_output().value_or(output); + return snapshot( + terminal.status == invocation_status::succeeded + ? local_run_status::succeeded + : local_run_status::failed, + terminal); + } + if (output.status == invocation_status::retry) { + continue; + } + + if (service_.advance_next_automatic_event( + options_.auto_advance_callback_timeouts)) { + continue; + } + if (const auto terminal = service_.terminal_output()) { + return snapshot( + local_run_status::timed_out, *terminal, + "execution timeout"); + } + if (service_.has_external_work()) { + return snapshot( + local_run_status::pending_external, output, + "waiting for callback or unmocked chained invoke"); + } + return snapshot( + local_run_status::deadlocked, output, + "pending execution has no resumable operation"); + } + + return snapshot( + local_run_status::invocation_limit_exceeded, + invocation_output{ + .status = invocation_status::retry, + .result = std::nullopt, + .error = local_error( + "Local invocation limit exceeded", + "LocalInvocationLimit"), + }, + "invocation limit exceeded"); +} + +void local_runner::send_callback_success( + std::string_view callback_id, + std::optional serialized_result) { + service_.send_callback_success( + callback_id, std::move(serialized_result)); +} + +void local_runner::send_callback_failure( + std::string_view callback_id, error_object error) { + service_.send_callback_failure(callback_id, std::move(error)); +} + +void local_runner::send_callback_heartbeat( + std::string_view callback_id) { + service_.send_callback_heartbeat(callback_id); +} + +void local_runner::advance_time(std::chrono::seconds duration) { + service_.advance_time(duration); +} + +void local_runner::mock_invoke_success( + std::string function_name, std::string serialized_result) { + service_.mock_invoke_success( + std::move(function_name), std::move(serialized_result)); +} + +void local_runner::mock_invoke_failure( + std::string function_name, error_object error) { + service_.mock_invoke_failure( + std::move(function_name), std::move(error)); +} + +local_test_result local_runner::snapshot( + local_run_status status, invocation_output output, + std::string pending_reason) const { + return local_test_result{ + status, std::move(output), service_.operations(), + service_.updates(), invocation_count_, service_.virtual_time(), + std::move(pending_reason), service_.execution_arn()}; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/src/model.cpp b/src/model.cpp new file mode 100644 index 0000000..5df2569 --- /dev/null +++ b/src/model.cpp @@ -0,0 +1,204 @@ +#include "aws/durable_execution/model.hpp" + +#include +#include +#include +#include + +namespace aws::durable_execution { +inline namespace v1 { +namespace { + +[[nodiscard]] operation_update make_update( + const operation_identifier& identifier, operation_type type, + operation_action action) { + return operation_update{ + .operation_id = identifier.require_operation_id(), + .type = type, + .action = action, + .parent_id = identifier.parent_id, + .name = identifier.name, + .sub_type = identifier.sub_type, + .payload = std::nullopt, + .error = std::nullopt, + .context = std::nullopt, + .step = std::nullopt, + .wait = std::nullopt, + .callback = std::nullopt, + .chained_invoke = std::nullopt, + }; +} + +[[nodiscard]] std::string execution_result_id() { + static std::atomic sequence{0}; + const auto ticks = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + return "execution-result-" + std::to_string(ticks) + "-" + + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)); +} + +} // namespace + +const std::string& operation_identifier::require_operation_id() const { + if (!operation_id) { + throw std::invalid_argument( + "operation_id is required for non-execution operations"); + } + return *operation_id; +} + +operation_identifier operation_identifier::execution() { + return operation_identifier{ + .operation_id = std::nullopt, + .sub_type = std::string{operation_subtype::execution}, + .parent_id = std::nullopt, + .name = std::nullopt, + .type = std::nullopt, + }; +} + +operation_type_value operation_type_from_wire(std::string_view value) { + if (value == "EXECUTION") return operation_type::execution; + if (value == "CONTEXT") return operation_type::context; + if (value == "STEP") return operation_type::step; + if (value == "WAIT") return operation_type::wait; + if (value == "CALLBACK") return operation_type::callback; + if (value == "CHAINED_INVOKE") return operation_type::chained_invoke; + return operation_type_value::unknown(std::string{value}); +} + +operation_status_value operation_status_from_wire(std::string_view value) { + if (value == "STARTED") return operation_status::started; + if (value == "PENDING") return operation_status::pending; + if (value == "READY") return operation_status::ready; + if (value == "SUCCEEDED") return operation_status::succeeded; + if (value == "FAILED") return operation_status::failed; + if (value == "CANCELLED") return operation_status::cancelled; + if (value == "TIMED_OUT") return operation_status::timed_out; + if (value == "STOPPED") return operation_status::stopped; + return operation_status_value::unknown(std::string{value}); +} + +operation_update operation_update::step_start( + const operation_identifier& identifier) { + return make_update(identifier, operation_type::step, operation_action::start); +} + +operation_update operation_update::step_succeed( + const operation_identifier& identifier, std::string payload_value) { + auto update = + make_update(identifier, operation_type::step, operation_action::succeed); + update.payload = std::move(payload_value); + return update; +} + +operation_update operation_update::step_fail( + const operation_identifier& identifier, error_object error_value) { + auto update = + make_update(identifier, operation_type::step, operation_action::fail); + update.error = std::move(error_value); + return update; +} + +operation_update operation_update::step_retry( + const operation_identifier& identifier, + std::optional error_value, + std::uint32_t delay_seconds, + std::optional payload_value) { + auto update = + make_update(identifier, operation_type::step, operation_action::retry); + update.error = std::move(error_value); + update.payload = std::move(payload_value); + update.step = step_options{.next_attempt_delay_seconds = delay_seconds}; + return update; +} + +operation_update operation_update::wait_start( + const operation_identifier& identifier, std::uint32_t seconds) { + auto update = + make_update(identifier, operation_type::wait, operation_action::start); + update.wait = wait_options{.wait_seconds = seconds}; + return update; +} + +operation_update operation_update::callback_start( + const operation_identifier& identifier, callback_options options) { + auto update = + make_update(identifier, operation_type::callback, operation_action::start); + update.callback = std::move(options); + return update; +} + +operation_update operation_update::chained_invoke_start( + const operation_identifier& identifier, std::string payload_value, + chained_invoke_options options) { + auto update = make_update( + identifier, operation_type::chained_invoke, operation_action::start); + update.payload = std::move(payload_value); + update.chained_invoke = std::move(options); + return update; +} + +operation_update operation_update::context_start( + const operation_identifier& identifier) { + return make_update( + identifier, operation_type::context, operation_action::start); +} + +operation_update operation_update::context_succeed( + const operation_identifier& identifier, std::string payload_value, + bool replay_children) { + auto update = make_update( + identifier, operation_type::context, operation_action::succeed); + update.payload = std::move(payload_value); + update.context = context_options{.replay_children = replay_children}; + return update; +} + +operation_update operation_update::context_fail( + const operation_identifier& identifier, error_object error_value) { + auto update = + make_update(identifier, operation_type::context, operation_action::fail); + update.error = std::move(error_value); + return update; +} + +operation_update operation_update::execution_succeed(std::string payload_value) { + return operation_update{ + .operation_id = execution_result_id(), + .type = operation_type::execution, + .action = operation_action::succeed, + .parent_id = std::nullopt, + .name = std::nullopt, + .sub_type = std::nullopt, + .payload = std::move(payload_value), + .error = std::nullopt, + .context = std::nullopt, + .step = std::nullopt, + .wait = std::nullopt, + .callback = std::nullopt, + .chained_invoke = std::nullopt, + }; +} + +operation_update operation_update::execution_fail(error_object error_value) { + return operation_update{ + .operation_id = execution_result_id(), + .type = operation_type::execution, + .action = operation_action::fail, + .parent_id = std::nullopt, + .name = std::nullopt, + .sub_type = std::nullopt, + .payload = std::nullopt, + .error = std::move(error_value), + .context = std::nullopt, + .step = std::nullopt, + .wait = std::nullopt, + .callback = std::nullopt, + .chained_invoke = std::nullopt, + }; +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/src/plugin.cpp b/src/plugin.cpp new file mode 100644 index 0000000..4eeb786 --- /dev/null +++ b/src/plugin.cpp @@ -0,0 +1,265 @@ +#include "aws/durable_execution/plugin.hpp" + +#include + +namespace aws::durable_execution { +inline namespace v1 { +namespace detail { +namespace { + +[[nodiscard]] std::optional optional_view( + const std::optional& value) noexcept { + return value ? std::optional{*value} : std::nullopt; +} + +[[nodiscard]] std::optional operation_result( + const operation& value) noexcept { + if (value.context && value.context->result) { + return *value.context->result; + } + if (value.step && value.step->result) { + return *value.step->result; + } + if (value.callback && value.callback->result) { + return *value.callback->result; + } + if (value.chained_invoke && value.chained_invoke->result) { + return *value.chained_invoke->result; + } + return std::nullopt; +} + +[[nodiscard]] const error_object* operation_error( + const operation& value) noexcept { + if (value.context && value.context->error) { + return &*value.context->error; + } + if (value.step && value.step->error) { + return &*value.step->error; + } + if (value.callback && value.callback->error) { + return &*value.callback->error; + } + if (value.chained_invoke && value.chained_invoke->error) { + return &*value.chained_invoke->error; + } + return nullptr; +} + +template +void for_each_plugin( + const std::vector>& plugins, + Callback&& callback) noexcept { + for (const auto& plugin : plugins) { + if (!plugin) continue; + try { + callback(*plugin); + } catch (...) { + // Instrumentation must never affect durable execution semantics. + } + } +} + +[[nodiscard]] std::vector make_infos( + std::string_view execution_arn, + std::span values, + bool is_replay) { + std::vector infos; + infos.reserve(values.size()); + for (const auto& value : values) { + if (value) { + infos.push_back(plugin_manager::make_operation_info( + execution_arn, value, is_replay)); + } + } + return infos; +} + +} // namespace + +plugin_manager::plugin_manager(const run_options& options) + : plugins_(options.plugins) {} + +operation_info plugin_manager::make_operation_info( + std::string_view execution_arn, const operation_snapshot& value, + bool is_replay, bool is_replaying_children) noexcept { + operation_info info{ + .durable_execution_arn = execution_arn, + .id = value->operation_id, + .name = optional_view(value->name), + .type = value->type.wire_value(), + .sub_type = optional_view(value->sub_type), + .parent_id = optional_view(value->parent_id), + .status = value->status.wire_value().empty() + ? std::nullopt + : std::optional{ + value->status.wire_value()}, + .start_timestamp = value->start_timestamp, + .end_timestamp = value->end_timestamp, + .result = operation_result(*value), + .error = operation_error(*value), + .attempt = std::nullopt, + .is_replay = is_replay, + .is_replaying_children = is_replaying_children, + }; + if (value->step) { + info.attempt = + value->step->attempt == 0U ? 1U : value->step->attempt; + } + return info; +} + +void plugin_manager::invocation_start( + std::string_view request_id, std::string_view execution_arn, + std::string_view execution_input, + std::span operations, + std::span updated_operations, + std::optional execution_start_timestamp, + bool is_first_invocation) noexcept { + if (!enabled()) return; + auto operation_infos = make_infos( + execution_arn, operations, !is_first_invocation); + auto updated_infos = + make_infos(execution_arn, updated_operations, false); + const invocation_info info{ + .request_id = request_id, + .execution_arn = execution_arn, + .execution_input = execution_input, + .operations = operation_infos, + .updated_operations = updated_infos, + .execution_start_timestamp = execution_start_timestamp, + .is_first_invocation = is_first_invocation, + }; + for_each_plugin( + plugins_, [&](instrumentation_plugin& plugin) { + plugin.on_invocation_start(info); + }); +} + +void plugin_manager::invocation_end( + std::string_view request_id, std::string_view execution_arn, + std::string_view execution_input, + std::span operations, + std::optional execution_start_timestamp, + invocation_status status, const std::optional& result, + const std::optional& error, + bool is_first_invocation) noexcept { + if (!enabled()) return; + auto operation_infos = make_infos( + execution_arn, operations, !is_first_invocation); + const invocation_end_info info{ + .request_id = request_id, + .execution_arn = execution_arn, + .execution_input = execution_input, + .operations = operation_infos, + .execution_start_timestamp = execution_start_timestamp, + .status = status, + .execution_result = + result ? std::optional{*result} : std::nullopt, + .execution_error = error ? &*error : nullptr, + .is_first_invocation = is_first_invocation, + }; + for_each_plugin( + plugins_, [&](instrumentation_plugin& plugin) { + plugin.on_invocation_end(info); + }); +} + +void plugin_manager::operation_start( + std::string_view execution_arn, const operation_snapshot& value, + bool is_replay, bool is_replaying_children) noexcept { + if (!enabled() || !value) return; + const auto info = make_operation_info( + execution_arn, value, is_replay, is_replaying_children); + for_each_plugin( + plugins_, [&](instrumentation_plugin& plugin) { + plugin.on_operation_start(info); + }); +} + +void plugin_manager::operation_end( + std::string_view execution_arn, const operation_snapshot& value, + bool is_replay) noexcept { + if (!enabled() || !value) return; + const auto info = + make_operation_info(execution_arn, value, is_replay); + for_each_plugin( + plugins_, [&](instrumentation_plugin& plugin) { + plugin.on_operation_end(info); + }); +} + +void plugin_manager::attempt_start( + std::string_view execution_arn, const operation_snapshot& value, + std::uint32_t attempt, timestamp started, bool is_replay, + bool is_replaying_children) noexcept { + if (!enabled() || !value) return; + auto operation = make_operation_info( + execution_arn, value, is_replay, is_replaying_children); + operation.attempt = attempt; + operation.start_timestamp = started; + operation.end_timestamp.reset(); + const attempt_info info{ + .operation = operation, + .attempt = attempt, + .start_timestamp = started, + .end_timestamp = std::nullopt, + .succeeded = std::nullopt, + .error = nullptr, + }; + for_each_plugin( + plugins_, [&](instrumentation_plugin& plugin) { + plugin.on_attempt_start(info); + }); +} + +void plugin_manager::attempt_end( + std::string_view execution_arn, const operation_snapshot& value, + std::uint32_t attempt, timestamp started, timestamp ended, + bool succeeded, const error_object* error, bool is_replay, + bool is_replaying_children) noexcept { + if (!enabled() || !value) return; + auto operation = make_operation_info( + execution_arn, value, is_replay, is_replaying_children); + operation.attempt = attempt; + operation.start_timestamp = started; + operation.end_timestamp = ended; + operation.error = error; + const attempt_info info{ + .operation = operation, + .attempt = attempt, + .start_timestamp = started, + .end_timestamp = ended, + .succeeded = succeeded, + .error = error, + }; + for_each_plugin( + plugins_, [&](instrumentation_plugin& plugin) { + plugin.on_attempt_end(info); + }); +} + +void plugin_manager::operation_change( + std::string_view execution_arn, + std::span updated_operations, + std::span operations, + bool is_replay) noexcept { + if (!enabled()) return; + auto updated_infos = + make_infos(execution_arn, updated_operations, is_replay); + auto operation_infos = + make_infos(execution_arn, operations, is_replay); + const operation_change_info info{ + .execution_arn = execution_arn, + .updated_operations = updated_infos, + .operations = operation_infos, + }; + for_each_plugin( + plugins_, [&](instrumentation_plugin& plugin) { + plugin.on_operation_change(info); + }); +} + +} // namespace detail +} // namespace v1 +} // namespace aws::durable_execution diff --git a/src/wire.cpp b/src/wire.cpp new file mode 100644 index 0000000..10daecc --- /dev/null +++ b/src/wire.cpp @@ -0,0 +1,908 @@ +#include "aws/durable_execution/wire.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace aws::durable_execution { +inline namespace v1 { +namespace { + +struct parse_failure { + std::string message; + std::size_t offset; +}; + +class parsed_string { + public: + explicit parsed_string(std::string_view value) noexcept : view_(value) {} + explicit parsed_string(std::string value) : owned_(std::move(value)) {} + + [[nodiscard]] std::string_view view() const noexcept { + return owned_.empty() ? view_ : std::string_view{owned_}; + } + + [[nodiscard]] std::string str() const { return std::string{view()}; } + + private: + std::string_view view_; + std::string owned_; +}; + +class json_reader { + public: + explicit json_reader(std::string_view input) noexcept + : begin_(input.data()), current_(input.data()), end_(input.data() + input.size()) {} + + [[nodiscard]] std::size_t offset() const noexcept { + return static_cast(current_ - begin_); + } + + void finish() { + whitespace(); + if (current_ != end_) { + fail("Unexpected trailing JSON data"); + } + } + + template + void object(Function&& field) { + expect('{'); + whitespace(); + if (consume('}')) { + return; + } + while (true) { + const auto key = string(); + expect(':'); + field(key.view()); + whitespace(); + if (consume('}')) { + return; + } + expect(','); + } + } + + template + void array(Function&& element) { + expect('['); + whitespace(); + if (consume(']')) { + return; + } + std::size_t index = 0; + while (true) { + element(index++); + whitespace(); + if (consume(']')) { + return; + } + expect(','); + } + } + + [[nodiscard]] parsed_string string() { + whitespace(); + if (current_ == end_ || *current_ != '"') { + fail("Expected a JSON string"); + } + ++current_; + const char* segment = current_; + while (current_ != end_) { + const unsigned char character = + static_cast(*current_); + if (character == '"') { + const std::string_view result{ + segment, static_cast(current_ - segment)}; + ++current_; + return parsed_string{result}; + } + if (character == '\\') { + std::string output{ + segment, static_cast(current_ - segment)}; + decode_escaped_string(output); + return parsed_string{std::move(output)}; + } + if (character < 0x20U) { + fail("Unescaped control character in JSON string"); + } + ++current_; + } + fail("Unterminated JSON string"); + } + + [[nodiscard]] std::int64_t integer() { + whitespace(); + const char* start = current_; + if (current_ != end_ && *current_ == '-') { + ++current_; + } + if (current_ == end_ || *current_ < '0' || *current_ > '9') { + fail("Expected a JSON integer"); + } + if (*current_ == '0') { + ++current_; + if (current_ != end_ && *current_ >= '0' && *current_ <= '9') { + fail("Leading zero in JSON integer"); + } + } else { + while (current_ != end_ && *current_ >= '0' && *current_ <= '9') { + ++current_; + } + } + if (current_ != end_ && + (*current_ == '.' || *current_ == 'e' || *current_ == 'E')) { + fail("Expected an integer, not a fractional JSON number"); + } + + std::int64_t result = 0; + const auto [parsed_end, error] = std::from_chars(start, current_, result); + if (error != std::errc{} || parsed_end != current_) { + fail("JSON integer is outside the supported 64-bit range"); + } + return result; + } + + [[nodiscard]] bool boolean() { + whitespace(); + if (literal("true")) { + return true; + } + if (literal("false")) { + return false; + } + fail("Expected a JSON boolean"); + } + + [[nodiscard]] bool try_null() { + whitespace(); + return literal("null"); + } + + void skip(std::size_t depth = 0) { + if (depth > 128U) { + fail("JSON nesting exceeds the supported depth"); + } + whitespace(); + if (current_ == end_) { + fail("Expected a JSON value"); + } + switch (*current_) { + case '"': + (void)string(); + return; + case '{': + object([&](std::string_view) { skip(depth + 1U); }); + return; + case '[': + array([&](std::size_t) { skip(depth + 1U); }); + return; + case 't': + if (!literal("true")) fail("Invalid JSON literal"); + return; + case 'f': + if (!literal("false")) fail("Invalid JSON literal"); + return; + case 'n': + if (!literal("null")) fail("Invalid JSON literal"); + return; + default: + skip_number(); + return; + } + } + + [[nodiscard]] std::string_view raw_value() { + whitespace(); + const char* start = current_; + skip(); + return std::string_view{ + start, static_cast(current_ - start)}; + } + + private: + [[noreturn]] void fail(std::string message) const { + throw parse_failure{std::move(message), offset()}; + } + + void whitespace() noexcept { + while (current_ != end_) { + switch (*current_) { + case ' ': + case '\t': + case '\r': + case '\n': ++current_; break; + default: return; + } + } + } + + [[nodiscard]] bool consume(char expected) noexcept { + whitespace(); + if (current_ == end_ || *current_ != expected) { + return false; + } + ++current_; + return true; + } + + void expect(char expected) { + if (!consume(expected)) { + fail(std::string{"Expected '"} + expected + "'"); + } + } + + [[nodiscard]] bool literal(std::string_view value) noexcept { + if (static_cast(end_ - current_) < value.size() || + std::string_view{current_, value.size()} != value) { + return false; + } + current_ += static_cast(value.size()); + return true; + } + + [[nodiscard]] unsigned hex_digit(char value) { + if (value >= '0' && value <= '9') { + return static_cast(value - '0'); + } + if (value >= 'a' && value <= 'f') { + return static_cast(value - 'a' + 10); + } + if (value >= 'A' && value <= 'F') { + return static_cast(value - 'A' + 10); + } + fail("Invalid hexadecimal digit in JSON unicode escape"); + } + + [[nodiscard]] unsigned unicode_escape() { + if (end_ - current_ < 4) { + fail("Truncated JSON unicode escape"); + } + unsigned codepoint = 0; + for (int index = 0; index < 4; ++index) { + codepoint = (codepoint << 4U) | hex_digit(*current_++); + } + return codepoint; + } + + static void append_utf8(std::string& output, unsigned codepoint) { + if (codepoint <= 0x7FU) { + output.push_back(static_cast(codepoint)); + } else if (codepoint <= 0x7FFU) { + output.push_back(static_cast(0xC0U | (codepoint >> 6U))); + output.push_back(static_cast(0x80U | (codepoint & 0x3FU))); + } else if (codepoint <= 0xFFFFU) { + output.push_back(static_cast(0xE0U | (codepoint >> 12U))); + output.push_back( + static_cast(0x80U | ((codepoint >> 6U) & 0x3FU))); + output.push_back(static_cast(0x80U | (codepoint & 0x3FU))); + } else { + output.push_back(static_cast(0xF0U | (codepoint >> 18U))); + output.push_back( + static_cast(0x80U | ((codepoint >> 12U) & 0x3FU))); + output.push_back( + static_cast(0x80U | ((codepoint >> 6U) & 0x3FU))); + output.push_back(static_cast(0x80U | (codepoint & 0x3FU))); + } + } + + void decode_escaped_string(std::string& output) { + while (current_ != end_) { + const unsigned char character = + static_cast(*current_++); + if (character == '"') { + return; + } + if (character < 0x20U) { + fail("Unescaped control character in JSON string"); + } + if (character != '\\') { + output.push_back(static_cast(character)); + continue; + } + if (current_ == end_) { + fail("Truncated JSON escape sequence"); + } + switch (*current_++) { + case '"': output.push_back('"'); break; + case '\\': output.push_back('\\'); break; + case '/': output.push_back('/'); break; + case 'b': output.push_back('\b'); break; + case 'f': output.push_back('\f'); break; + case 'n': output.push_back('\n'); break; + case 'r': output.push_back('\r'); break; + case 't': output.push_back('\t'); break; + case 'u': { + unsigned codepoint = unicode_escape(); + if (codepoint >= 0xD800U && codepoint <= 0xDBFFU) { + if (end_ - current_ < 6 || current_[0] != '\\' || + current_[1] != 'u') { + fail("High surrogate is not followed by a low surrogate"); + } + current_ += 2; + const unsigned low = unicode_escape(); + if (low < 0xDC00U || low > 0xDFFFU) { + fail("Invalid low surrogate in JSON unicode escape"); + } + codepoint = + 0x10000U + ((codepoint - 0xD800U) << 10U) + (low - 0xDC00U); + } else if (codepoint >= 0xDC00U && codepoint <= 0xDFFFU) { + fail("Unexpected low surrogate in JSON unicode escape"); + } + append_utf8(output, codepoint); + break; + } + default: fail("Unknown JSON escape sequence"); + } + } + fail("Unterminated JSON string"); + } + + void skip_number() { + const char* start = current_; + if (current_ != end_ && *current_ == '-') { + ++current_; + } + if (current_ == end_ || *current_ < '0' || *current_ > '9') { + fail("Expected a JSON value"); + } + if (*current_ == '0') { + ++current_; + } else { + while (current_ != end_ && *current_ >= '0' && *current_ <= '9') { + ++current_; + } + } + if (current_ != end_ && *current_ == '.') { + ++current_; + const char* fraction = current_; + while (current_ != end_ && *current_ >= '0' && *current_ <= '9') { + ++current_; + } + if (fraction == current_) { + fail("Invalid JSON fraction"); + } + } + if (current_ != end_ && (*current_ == 'e' || *current_ == 'E')) { + ++current_; + if (current_ != end_ && (*current_ == '+' || *current_ == '-')) { + ++current_; + } + const char* exponent = current_; + while (current_ != end_ && *current_ >= '0' && *current_ <= '9') { + ++current_; + } + if (exponent == current_) { + fail("Invalid JSON exponent"); + } + } + if (start == current_) { + fail("Expected a JSON number"); + } + } + + const char* begin_; + const char* current_; + const char* end_; +}; + +[[nodiscard]] std::optional optional_string(json_reader& reader) { + if (reader.try_null()) { + return std::nullopt; + } + return reader.string().str(); +} + +[[nodiscard]] std::optional optional_timestamp(json_reader& reader) { + if (reader.try_null()) { + return std::nullopt; + } + const auto millis = reader.integer(); + return timestamp{ + std::chrono::duration_cast( + std::chrono::milliseconds{millis})}; +} + +[[nodiscard]] std::uint32_t unsigned_32( + json_reader& reader, std::string_view field) { + const auto value = reader.integer(); + if (value < 0 || + static_cast(value) > + std::numeric_limits::max()) { + throw parse_failure{ + std::string{field} + " is outside the supported unsigned 32-bit range", + reader.offset()}; + } + return static_cast(value); +} + +[[nodiscard]] error_object parse_error(json_reader& reader) { + error_object result; + reader.object([&](std::string_view key) { + if (key == "ErrorMessage") { + result.message = optional_string(reader); + } else if (key == "ErrorType") { + result.type = optional_string(reader); + } else if (key == "ErrorData") { + result.data = optional_string(reader); + } else if (key == "StackTrace") { + if (reader.try_null()) { + result.stack_trace.clear(); + } else { + result.stack_trace.clear(); + reader.array([&](std::size_t) { + result.stack_trace.push_back(reader.string().str()); + }); + } + } else { + reader.skip(); + } + }); + return result; +} + +[[nodiscard]] std::optional optional_error(json_reader& reader) { + if (reader.try_null()) { + return std::nullopt; + } + return parse_error(reader); +} + +[[nodiscard]] execution_details parse_execution_details(json_reader& reader) { + execution_details result; + reader.object([&](std::string_view key) { + if (key == "InputPayload") { + result.input_payload = optional_string(reader); + } else { + reader.skip(); + } + }); + return result; +} + +[[nodiscard]] context_details parse_context_details(json_reader& reader) { + context_details result; + reader.object([&](std::string_view key) { + if (key == "ReplayChildren") { + result.replay_children = reader.boolean(); + } else if (key == "Result") { + result.result = optional_string(reader); + } else if (key == "Error") { + result.error = optional_error(reader); + } else { + reader.skip(); + } + }); + return result; +} + +[[nodiscard]] step_details parse_step_details(json_reader& reader) { + step_details result; + reader.object([&](std::string_view key) { + if (key == "Attempt") { + result.attempt = unsigned_32(reader, "Attempt"); + } else if (key == "NextAttemptTimestamp") { + result.next_attempt_timestamp = optional_timestamp(reader); + } else if (key == "Result") { + result.result = optional_string(reader); + } else if (key == "Error") { + result.error = optional_error(reader); + } else { + reader.skip(); + } + }); + return result; +} + +[[nodiscard]] wait_details parse_wait_details(json_reader& reader) { + wait_details result; + reader.object([&](std::string_view key) { + if (key == "ScheduledEndTimestamp") { + result.scheduled_end_timestamp = optional_timestamp(reader); + } else { + reader.skip(); + } + }); + return result; +} + +[[nodiscard]] callback_details parse_callback_details(json_reader& reader) { + callback_details result; + reader.object([&](std::string_view key) { + if (key == "CallbackId") { + result.callback_id = reader.string().str(); + } else if (key == "Result") { + result.result = optional_string(reader); + } else if (key == "Error") { + result.error = optional_error(reader); + } else { + reader.skip(); + } + }); + return result; +} + +[[nodiscard]] chained_invoke_details parse_chained_invoke_details( + json_reader& reader) { + chained_invoke_details result; + reader.object([&](std::string_view key) { + if (key == "Result") { + result.result = optional_string(reader); + } else if (key == "Error") { + result.error = optional_error(reader); + } else { + reader.skip(); + } + }); + return result; +} + +[[nodiscard]] operation parse_operation(json_reader& reader) { + operation result; + bool has_id = false; + bool has_type = false; + bool has_status = false; + + reader.object([&](std::string_view key) { + if (key == "Id") { + result.operation_id = reader.string().str(); + has_id = true; + } else if (key == "ParentId") { + result.parent_id = optional_string(reader); + } else if (key == "Name") { + result.name = optional_string(reader); + } else if (key == "Type") { + const auto value = reader.string(); + if (value.view().empty()) { + throw parse_failure{ + "Operation Type must not be empty", reader.offset()}; + } + result.type = operation_type_from_wire(value.view()); + has_type = true; + } else if (key == "SubType") { + result.sub_type = optional_string(reader); + } else if (key == "StartTimestamp") { + result.start_timestamp = optional_timestamp(reader); + } else if (key == "EndTimestamp") { + result.end_timestamp = optional_timestamp(reader); + } else if (key == "Status") { + const auto value = reader.string(); + if (value.view().empty()) { + throw parse_failure{ + "Operation Status must not be empty", reader.offset()}; + } + result.status = operation_status_from_wire(value.view()); + has_status = true; + } else if (key == "ExecutionDetails") { + result.execution = + reader.try_null() + ? std::nullopt + : std::optional{ + parse_execution_details(reader)}; + } else if (key == "ContextDetails") { + result.context = + reader.try_null() + ? std::nullopt + : std::optional{parse_context_details(reader)}; + } else if (key == "StepDetails") { + result.step = + reader.try_null() + ? std::nullopt + : std::optional{parse_step_details(reader)}; + } else if (key == "WaitDetails") { + result.wait = + reader.try_null() + ? std::nullopt + : std::optional{parse_wait_details(reader)}; + } else if (key == "CallbackDetails") { + result.callback = + reader.try_null() + ? std::nullopt + : std::optional{ + parse_callback_details(reader)}; + } else if (key == "ChainedInvokeDetails") { + result.chained_invoke = + reader.try_null() + ? std::nullopt + : std::optional{ + parse_chained_invoke_details(reader)}; + } else { + reader.skip(); + } + }); + + if (!has_id || result.operation_id.empty()) { + throw parse_failure{"Operation is missing required Id", reader.offset()}; + } + if (!has_type) { + throw parse_failure{"Operation is missing required Type", reader.offset()}; + } + if (!has_status) { + throw parse_failure{"Operation is missing required Status", reader.offset()}; + } + return result; +} + +[[nodiscard]] initial_execution_state parse_initial_state(json_reader& reader) { + initial_execution_state result; + reader.object([&](std::string_view key) { + if (key == "Operations") { + if (reader.try_null()) { + result.operations.clear(); + } else { + result.operations.clear(); + reader.array([&](std::size_t) { + result.operations.push_back(parse_operation(reader)); + }); + } + } else if (key == "NextMarker") { + result.next_marker = optional_string(reader); + } else { + reader.skip(); + } + }); + return result; +} + +[[nodiscard]] invocation_input parse_invocation_input(json_reader& reader) { + invocation_input result; + bool has_arn = false; + bool has_token = false; + reader.object([&](std::string_view key) { + if (key == "DurableExecutionArn") { + result.durable_execution_arn = reader.string().str(); + has_arn = true; + } else if (key == "CheckpointToken") { + result.checkpoint_token = reader.string().str(); + has_token = true; + } else if (key == "UpdatedOperationIds") { + if (reader.try_null()) { + result.updated_operation_ids.clear(); + } else { + result.updated_operation_ids.clear(); + reader.array([&](std::size_t) { + result.updated_operation_ids.push_back(reader.string().str()); + }); + } + } else if (key == "InitialExecutionState") { + result.initial_state = + reader.try_null() ? initial_execution_state{} + : parse_initial_state(reader); + } else { + reader.skip(); + } + }); + if (!has_arn || result.durable_execution_arn.empty()) { + throw parse_failure{ + "Invocation input is missing DurableExecutionArn", reader.offset()}; + } + if (!has_token || result.checkpoint_token.empty()) { + throw parse_failure{ + "Invocation input is missing CheckpointToken", reader.offset()}; + } + return result; +} + +[[nodiscard]] invocation_status parse_invocation_status( + std::string_view value, std::size_t offset) { + if (value == "SUCCEEDED") return invocation_status::succeeded; + if (value == "FAILED") return invocation_status::failed; + if (value == "PENDING") return invocation_status::pending; + if (value == "RETRY") return invocation_status::retry; + throw parse_failure{"Unknown invocation Status: " + std::string{value}, offset}; +} + +[[nodiscard]] invocation_output parse_invocation_output(json_reader& reader) { + invocation_output result; + bool has_status = false; + reader.object([&](std::string_view key) { + if (key == "Status") { + const auto value = reader.string(); + result.status = parse_invocation_status(value.view(), reader.offset()); + has_status = true; + } else if (key == "Result") { + result.result = optional_string(reader); + } else if (key == "Error") { + result.error = optional_error(reader); + } else { + reader.skip(); + } + }); + if (!has_status) { + throw parse_failure{ + "Invocation output is missing required Status", reader.offset()}; + } + return result; +} + +void append_quoted(std::string& output, std::string_view value) { + constexpr char hex[] = "0123456789abcdef"; + output.push_back('"'); + for (const unsigned char character : value) { + switch (character) { + case '"': output.append("\\\""); break; + case '\\': output.append("\\\\"); break; + case '\b': output.append("\\b"); break; + case '\f': output.append("\\f"); break; + case '\n': output.append("\\n"); break; + case '\r': output.append("\\r"); break; + case '\t': output.append("\\t"); break; + default: + if (character < 0x20U) { + output.append("\\u00"); + output.push_back(hex[character >> 4U]); + output.push_back(hex[character & 0x0FU]); + } else { + output.push_back(static_cast(character)); + } + } + } + output.push_back('"'); +} + +void append_name(std::string& output, bool& first, std::string_view name) { + if (!first) { + output.push_back(','); + } + first = false; + append_quoted(output, name); + output.push_back(':'); +} + +void append_error(std::string& output, const error_object& error) { + output.push_back('{'); + bool first = true; + if (error.message) { + append_name(output, first, "ErrorMessage"); + append_quoted(output, *error.message); + } + if (error.type) { + append_name(output, first, "ErrorType"); + append_quoted(output, *error.type); + } + if (error.data) { + append_name(output, first, "ErrorData"); + append_quoted(output, *error.data); + } + if (!error.stack_trace.empty()) { + append_name(output, first, "StackTrace"); + output.push_back('['); + bool first_frame = true; + for (const auto& frame : error.stack_trace) { + if (!first_frame) { + output.push_back(','); + } + first_frame = false; + append_quoted(output, frame); + } + output.push_back(']'); + } + output.push_back('}'); +} + +} // namespace + +std::expected decode_invocation_input( + std::string_view json) { + try { + json_reader reader{json}; + auto result = parse_invocation_input(reader); + reader.finish(); + return result; + } catch (const parse_failure& error) { + return std::unexpected(wire_error{ + .message = error.message, + .offset = error.offset, + }); + } +} + +std::expected decode_invocation_output( + std::string_view json) { + try { + json_reader reader{json}; + auto result = parse_invocation_output(reader); + reader.finish(); + return result; + } catch (const parse_failure& error) { + return std::unexpected(wire_error{ + .message = error.message, + .offset = error.offset, + }); + } +} + +std::string encode_invocation_output(const invocation_output& output_value) { + std::string output; + output.reserve( + 32U + (output_value.result ? output_value.result->size() : 0U) + + (output_value.error && output_value.error->message + ? output_value.error->message->size() + : 0U)); + output.push_back('{'); + bool first = true; + + append_name(output, first, "Status"); + append_quoted(output, to_string(output_value.status)); + if (output_value.result) { + append_name(output, first, "Result"); + append_quoted(output, *output_value.result); + } + if (output_value.error) { + append_name(output, first, "Error"); + append_error(output, *output_value.error); + } + output.push_back('}'); + return output; +} + +std::expected, wire_error> +json_object_integer_field( + std::string_view json, std::string_view field_name) { + try { + json_reader reader{json}; + std::optional result; + reader.object([&](std::string_view key) { + if (key == field_name) { + result = reader.integer(); + } else { + reader.skip(); + } + }); + reader.finish(); + return result; + } catch (const parse_failure& error) { + return std::unexpected(wire_error{ + .message = error.message, + .offset = error.offset, + }); + } +} + +std::expected +set_json_object_integer_field( + std::string_view json, std::string_view field_name, + std::int64_t value) { + try { + json_reader reader{json}; + std::string output; + output.reserve(json.size() + field_name.size() + 32U); + output.push_back('{'); + bool first = true; + reader.object([&](std::string_view key) { + const auto raw_value = reader.raw_value(); + if (key == field_name) { + return; + } + append_name(output, first, key); + output.append(raw_value); + }); + reader.finish(); + + append_name(output, first, field_name); + char buffer[32]; + const auto [end, conversion_error] = + std::to_chars(std::begin(buffer), std::end(buffer), value); + if (conversion_error != std::errc{}) { + throw parse_failure{ + "Failed to encode JSON integer field", json.size()}; + } + output.append(buffer, end); + output.push_back('}'); + return output; + } catch (const parse_failure& error) { + return std::unexpected(wire_error{ + .message = error.message, + .offset = error.offset, + }); + } +} + +} // namespace v1 +} // namespace aws::durable_execution diff --git a/tests/fixtures/python_invocation.json b/tests/fixtures/python_invocation.json new file mode 100644 index 0000000..0d6e99b --- /dev/null +++ b/tests/fixtures/python_invocation.json @@ -0,0 +1,78 @@ +{ + "DurableExecutionArn": "arn:aws:lambda:us-east-1:123456789012:function:fixture/fixture-execution", + "CheckpointToken": "fixture-token-7", + "InitialExecutionState": { + "Operations": [ + { + "Id": "fixture-execution", + "Type": "EXECUTION", + "Status": "STARTED", + "Name": "fixture", + "StartTimestamp": 1787745600123, + "SubType": "Execution", + "ExecutionDetails": { + "InputPayload": "{\"value\":1}" + } + }, + { + "Id": "step-1", + "Type": "STEP", + "Status": "PENDING", + "Name": "poll", + "StartTimestamp": 1787745601000, + "SubType": "WaitForCondition", + "StepDetails": { + "Attempt": 2, + "NextAttemptTimestamp": 1787745606000, + "Result": "2", + "Error": { + "ErrorMessage": "retry", + "ErrorType": "Retryable" + } + } + }, + { + "Id": "wait-1", + "Type": "WAIT", + "Status": "STARTED", + "Name": "wait", + "SubType": "Wait", + "WaitDetails": { + "ScheduledEndTimestamp": 1787745660000 + } + }, + { + "Id": "callback-1", + "Type": "CALLBACK", + "Status": "STARTED", + "Name": "approval", + "SubType": "Callback", + "CallbackDetails": { + "CallbackId": "callback-token" + } + }, + { + "Id": "invoke-1", + "Type": "CHAINED_INVOKE", + "Status": "SUCCEEDED", + "Name": "invoke", + "SubType": "ChainedInvoke", + "ChainedInvokeDetails": { + "Result": "\"done\"" + } + }, + { + "Id": "context-1", + "Type": "CONTEXT", + "Status": "SUCCEEDED", + "Name": "child", + "SubType": "RunInChildContext", + "ContextDetails": { + "ReplayChildren": true, + "Result": "summary" + } + } + ], + "NextMarker": "next-page" + } +} diff --git a/tests/fixtures/python_operation_ids.tsv b/tests/fixtures/python_operation_ids.tsv new file mode 100644 index 0000000..962f912 --- /dev/null +++ b/tests/fixtures/python_operation_ids.tsv @@ -0,0 +1,4 @@ +sequential 1 1ced8f5be2db23a6513eba4d819c73806424748a7bc6fa0d792cc1c7d1775a97 +sequential root 1 0f93cb234535d36133a651719ef6c6dbd4129193f8c771dcf84d2614c6209dd4 +local alpha 5324af366925933a4afc432e5430eb5b7fd62833444d7cd5518458ad3f2205e9 +local root alpha ae5d2a3d2b65e4f0fceb3dea2d1755b54c64c5cb865c639140b2d3ae9b314465 diff --git a/tests/fixtures/python_serdes.tsv b/tests/fixtures/python_serdes.tsv new file mode 100644 index 0000000..31c704e --- /dev/null +++ b/tests/fixtures/python_serdes.tsv @@ -0,0 +1,7 @@ +null null +bool_true true +int_negative -42 +float 3.25 +string "hello\nworld" +uuid {"t":"u","v":"12345678-1234-4abc-8def-1234567890ab"} +datetime {"t":"dt","v":"2026-08-26T12:34:56.789000+00:00"} diff --git a/tests/optional/lambda_runtime_compile.cpp b/tests/optional/lambda_runtime_compile.cpp new file mode 100644 index 0000000..c6ade6c --- /dev/null +++ b/tests/optional/lambda_runtime_compile.cpp @@ -0,0 +1,27 @@ +#include + +#include "aws/durable_execution/lambda_runtime.hpp" + +namespace durable = aws::durable_execution; + +class compile_service_client final : public durable::service_client { + public: + std::expected checkpoint( + const durable::checkpoint_request&) override { + return durable::checkpoint_output{}; + } + + std::expected + get_execution_state(const durable::get_state_request&) override { + return durable::state_output{}; + } +}; + +void compile_lambda_runtime_adapter() { + compile_service_client client; + auto handler = durable::make_lambda_handler( + client, [](std::string_view) { return 42; }); + static_assert(std::invocable< + decltype(handler)&, + const aws::lambda_runtime::invocation_request&>); +} diff --git a/tests/package_consumer/CMakeLists.txt b/tests/package_consumer/CMakeLists.txt new file mode 100644 index 0000000..4f24910 --- /dev/null +++ b/tests/package_consumer/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.25) +project(durable_execution_package_consumer LANGUAGES CXX) + +find_package(aws_durable_execution 0.1 CONFIG REQUIRED) + +option(DURABLE_EXPECT_OPTIONAL_TARGETS "Require optional AWS integration targets" OFF) +if(DURABLE_EXPECT_OPTIONAL_TARGETS) + if(NOT TARGET aws::durable_execution_aws_sdk) + message(FATAL_ERROR "AWS SDK adapter target was not exported") + endif() + if(NOT TARGET aws::durable_execution_lambda_runtime) + message(FATAL_ERROR "Lambda runtime adapter target was not exported") + endif() +endif() + +add_executable(package_consumer main.cpp) +target_link_libraries(package_consumer PRIVATE aws::durable_execution) diff --git a/tests/package_consumer/main.cpp b/tests/package_consumer/main.cpp new file mode 100644 index 0000000..fc95bd9 --- /dev/null +++ b/tests/package_consumer/main.cpp @@ -0,0 +1,24 @@ +#include +#include + +#include + +class consumer_plugin final + : public aws::durable_execution::instrumentation_plugin {}; + +int main() { + static_assert(aws::durable_execution::abi_version == "1"); + static_assert( + aws::durable_execution::instrumentation_plugin_api_version == 1); + static_assert( + std::is_move_constructible_v< + aws::durable_execution::extension_operation>); + static_assert( + !std::is_copy_constructible_v< + aws::durable_execution::extension_operation>); + aws::durable_execution::run_options options{ + .plugins = {std::make_shared()}, + }; + aws::durable_execution::operation_id_generator generator; + return generator.next().empty() || options.plugins.size() != 1U ? 1 : 0; +} diff --git a/tests/test_main.cpp b/tests/test_main.cpp new file mode 100644 index 0000000..4084582 --- /dev/null +++ b/tests/test_main.cpp @@ -0,0 +1,3138 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aws/durable_execution/durable_execution.hpp" + +namespace durable = aws::durable_execution; +using namespace std::chrono_literals; + +namespace { + +int failures = 0; + +#define CHECK(expression) \ + do { \ + if (!(expression)) { \ + std::cerr << __FILE__ << ':' << __LINE__ << ": CHECK failed: " \ + << #expression << '\n'; \ + ++failures; \ + } \ + } while (false) + +class memory_service_client final : public durable::service_client { + public: + std::expected checkpoint( + const durable::checkpoint_request& request) override { + if (checkpoint_failure) { + return std::unexpected(*checkpoint_failure); + } + ++checkpoint_calls; + checkpoint_batch_sizes.push_back(request.updates.size()); + std::vector changed; + changed.reserve(request.updates.size()); + received_updates.insert( + received_updates.end(), request.updates.begin(), request.updates.end()); + bool terminal = false; + + for (const auto& update : request.updates) { + auto found = operations.find(update.operation_id); + durable::operation value = + found == operations.end() + ? durable::operation{ + .operation_id = update.operation_id, + .type = update.type, + .status = durable::operation_status::started, + .parent_id = update.parent_id, + .name = update.name, + .sub_type = update.sub_type, + } + : found->second; + + value.type = update.type; + value.parent_id = update.parent_id; + value.name = update.name; + value.sub_type = update.sub_type; + + switch (update.action) { + case durable::operation_action::start: + value.status = durable::operation_status::started; + if (update.type == durable::operation_type::step && !value.step) { + value.step = durable::step_details{}; + } + if (update.type == durable::operation_type::step && + drop_step_result_on_start && value.step) { + value.step->result.reset(); + } + if (update.type == durable::operation_type::wait) { + value.wait = durable::wait_details{ + .scheduled_end_timestamp = + std::chrono::system_clock::now() + + std::chrono::seconds{update.wait->wait_seconds}, + }; + } + if (update.type == durable::operation_type::callback) { + value.callback = durable::callback_details{ + .callback_id = "callback-" + update.operation_id, + }; + } + if (update.type == durable::operation_type::chained_invoke) { + value.chained_invoke = durable::chained_invoke_details{}; + } + if (update.type == durable::operation_type::context) { + value.context = durable::context_details{}; + } + break; + case durable::operation_action::succeed: + value.status = durable::operation_status::succeeded; + if (update.type == durable::operation_type::step) { + if (!value.step) value.step = durable::step_details{}; + ++value.step->attempt; + value.step->result = update.payload; + } + if (update.type == durable::operation_type::context) { + if (!value.context) value.context = durable::context_details{}; + value.context->result = update.payload; + value.context->replay_children = + update.context && update.context->replay_children; + } + break; + case durable::operation_action::fail: + value.status = durable::operation_status::failed; + if (update.type == durable::operation_type::step) { + if (!value.step) value.step = durable::step_details{}; + ++value.step->attempt; + value.step->error = update.error; + } + if (update.type == durable::operation_type::context) { + if (!value.context) value.context = durable::context_details{}; + value.context->error = update.error; + } + break; + case durable::operation_action::retry: + value.status = durable::operation_status::pending; + if (!value.step) value.step = durable::step_details{}; + ++value.step->attempt; + value.step->error = update.error; + value.step->result = update.payload; + value.step->next_attempt_timestamp = + std::chrono::system_clock::now() + + std::chrono::seconds{ + update.step->next_attempt_delay_seconds}; + break; + case durable::operation_action::cancel: + value.status = durable::operation_status::cancelled; + break; + } + + if (update.type == durable::operation_type::execution && + (update.action == durable::operation_action::succeed || + update.action == durable::operation_action::fail)) { + terminal = true; + } + operations.insert_or_assign(value.operation_id, value); + changed.push_back(std::move(value)); + } + + return durable::checkpoint_output{ + .checkpoint_token = + terminal + ? std::nullopt + : std::optional{ + "token-" + std::to_string(checkpoint_calls)}, + .operations = std::move(changed), + }; + } + + std::expected + get_execution_state(const durable::get_state_request&) override { + ++state_calls; + if (state_failure) { + return std::unexpected(*state_failure); + } + return durable::state_output{}; + } + + std::optional checkpoint_failure; + std::optional state_failure; + bool drop_step_result_on_start{false}; + std::unordered_map operations; + std::vector received_updates; + std::vector checkpoint_batch_sizes; + int checkpoint_calls{0}; + int state_calls{0}; +}; + +class recording_plugin final : public durable::instrumentation_plugin { + public: + struct operation_record { + std::string id; + std::string type; + std::string sub_type; + std::string status; + std::string result; + std::string error; + std::string parent_id; + std::uint32_t attempt{}; + bool is_replay{}; + bool is_replaying_children{}; + bool has_start_timestamp{}; + bool has_end_timestamp{}; + }; + + struct attempt_record { + std::string id; + std::uint32_t attempt{}; + std::optional succeeded; + bool is_replay{}; + bool is_replaying_children{}; + }; + + explicit recording_plugin(bool throw_after_hook = false) + : throw_after_hook_(throw_after_hook) {} + + void on_invocation_start( + const durable::invocation_info& info) override { + std::lock_guard lock{mutex_}; + invocation_first.push_back(info.is_first_invocation); + request_ids.emplace_back(info.request_id); + updated_counts.push_back(info.updated_operations.size()); + maybe_throw(); + } + + void on_invocation_end( + const durable::invocation_end_info& info) override { + std::lock_guard lock{mutex_}; + invocation_statuses.emplace_back( + durable::to_string(info.status)); + invocation_end_first.push_back(info.is_first_invocation); + maybe_throw(); + } + + void on_operation_start( + const durable::operation_info& info) override { + std::lock_guard lock{mutex_}; + operation_starts.push_back(to_record(info)); + maybe_throw(); + } + + void on_operation_end( + const durable::operation_info& info) override { + std::lock_guard lock{mutex_}; + operation_ends.push_back(to_record(info)); + maybe_throw(); + } + + void on_attempt_start( + const durable::attempt_info& info) override { + std::lock_guard lock{mutex_}; + attempt_starts.push_back(attempt_record{ + .id = std::string{info.operation.id}, + .attempt = info.attempt, + .succeeded = info.succeeded, + .is_replay = info.operation.is_replay, + .is_replaying_children = + info.operation.is_replaying_children, + }); + maybe_throw(); + } + + void on_attempt_end( + const durable::attempt_info& info) override { + std::lock_guard lock{mutex_}; + attempt_ends.push_back(attempt_record{ + .id = std::string{info.operation.id}, + .attempt = info.attempt, + .succeeded = info.succeeded, + .is_replay = info.operation.is_replay, + .is_replaying_children = + info.operation.is_replaying_children, + }); + maybe_throw(); + } + + void on_operation_change( + const durable::operation_change_info& info) override { + std::lock_guard lock{mutex_}; + change_updated_counts.push_back(info.updated_operations.size()); + change_operation_counts.push_back(info.operations.size()); + maybe_throw(); + } + + std::vector invocation_first; + std::vector invocation_end_first; + std::vector invocation_statuses; + std::vector request_ids; + std::vector updated_counts; + std::vector operation_starts; + std::vector operation_ends; + std::vector attempt_starts; + std::vector attempt_ends; + std::vector change_updated_counts; + std::vector change_operation_counts; + + private: + [[nodiscard]] static operation_record to_record( + const durable::operation_info& info) { + return operation_record{ + .id = std::string{info.id}, + .type = std::string{info.type}, + .sub_type = + info.sub_type ? std::string{*info.sub_type} : std::string{}, + .status = + info.status ? std::string{*info.status} : std::string{}, + .result = + info.result ? std::string{*info.result} : std::string{}, + .error = + info.error && info.error->message + ? *info.error->message + : std::string{}, + .parent_id = + info.parent_id ? std::string{*info.parent_id} : std::string{}, + .attempt = info.attempt.value_or(0U), + .is_replay = info.is_replay, + .is_replaying_children = info.is_replaying_children, + .has_start_timestamp = info.start_timestamp.has_value(), + .has_end_timestamp = info.end_timestamp.has_value(), + }; + } + + void maybe_throw() const { + if (throw_after_hook_) { + throw std::runtime_error{"instrumentation failure"}; + } + } + + bool throw_after_hook_; + std::mutex mutex_; +}; + +durable::invocation_input input_with( + std::vector history = {}) { + durable::operation root{ + .operation_id = "execution-1", + .type = durable::operation_type::execution, + .status = durable::operation_status::started, + .sub_type = std::string{durable::operation_subtype::execution}, + .execution = durable::execution_details{ + .input_payload = R"({"order_id":"order-123"})", + }, + }; + std::vector operations; + operations.reserve(history.size() + 1U); + operations.push_back(std::move(root)); + for (auto& operation : history) { + operations.push_back(std::move(operation)); + } + return durable::invocation_input{ + .durable_execution_arn = + "arn:aws:lambda:us-east-1:123456789012:function:orders/execution-1", + .checkpoint_token = "token-0", + .initial_state = durable::initial_execution_state{ + .operations = std::move(operations), + }, + .updated_operation_ids = {}, + }; +} + +std::string first_operation_id() { + durable::operation_id_generator generator; + return generator.next(); +} + +std::string read_fixture(std::string_view path) { + std::ifstream input{std::string{path}, std::ios::binary}; + if (!input) { + throw std::runtime_error( + "Unable to open fixture: " + std::string{path}); + } + return std::string{ + std::istreambuf_iterator{input}, + std::istreambuf_iterator{}}; +} + +void test_operation_id_golden_vectors() { + durable::operation_id_generator generator; + CHECK( + generator.next() == + "1ced8f5be2db23a6513eba4d819c73806424748a7bc6fa0d792cc1c7d1775a97"); + + durable::operation_id_generator prefixed{std::string{"root"}}; + CHECK( + prefixed.next() == + "0f93cb234535d36133a651719ef6c6dbd4129193f8c771dcf84d2614c6209dd4"); + + durable::operation_id_generator local; + CHECK( + local.reserve("alpha") == + "5324af366925933a4afc432e5430eb5b7fd62833444d7cd5518458ad3f2205e9"); +} + +void test_default_serdes() { + const durable::serdes_context context{}; + const durable::default_serdes strings; + const std::string value{"line 1\n\"quoted\""}; + const auto encoded = strings.serialize(value, context); + CHECK(encoded == R"("line 1\n\"quoted\"")"); + CHECK(strings.deserialize(encoded, context) == value); + + const durable::default_serdes integers; + CHECK(integers.serialize(-42, context) == "-42"); + CHECK(integers.deserialize("12345", context) == 12345); + + const durable::optional_serdes optionals; + const auto present = + optionals.serialize(std::optional{"value"}, context); + CHECK(present == R"({"p":true,"v":"\"value\""})"); + CHECK( + optionals.deserialize(present, context) == + std::optional{"value"}); + CHECK( + !optionals.deserialize( + optionals.serialize(std::optional{}, context), + context)); +} + +void test_forward_compatible_wire_enums() { + const auto known_type = durable::operation_type_from_wire("STEP"); + CHECK(known_type.is_known()); + CHECK(known_type == durable::operation_type::step); + CHECK(known_type.wire_value() == "STEP"); + + const auto future_type = + durable::operation_type_from_wire("FUTURE_DISTRIBUTED_STEP"); + CHECK(!future_type.is_known()); + CHECK(future_type != durable::operation_type::step); + CHECK(future_type.wire_value() == "FUTURE_DISTRIBUTED_STEP"); + + const auto future_status = + durable::operation_status_from_wire("PAUSED_BY_POLICY"); + CHECK(!future_status.is_known()); + CHECK(future_status.wire_value() == "PAUSED_BY_POLICY"); +} + +void test_invocation_wire_codec() { + constexpr std::string_view json = R"JSON( + { + "DurableExecutionArn": + "arn:aws:lambda:us-east-1:123456789012:function:orders/execution-1", + "CheckpointToken": "token-0", + "UpdatedOperationIds": ["step-1", "future-1"], + "FutureEnvelope": {"nested": [1.25e2, true, null, {"value": "ignored"}]}, + "InitialExecutionState": { + "NextMarker": "marker-1", + "UnknownStateField": false, + "Operations": [ + { + "Id": "execution-1", + "Type": "EXECUTION", + "Status": "STARTED", + "SubType": "Execution", + "StartTimestamp": 1769481309631, + "ExecutionDetails": { + "InputPayload": "{\"order_id\":\"order-123\"}", + "FutureDetail": [1, 2, 3] + } + }, + { + "Id": "step-1", + "ParentId": null, + "Name": "approval \uD83D\uDE80", + "Type": "STEP", + "SubType": "Step", + "Status": "PENDING", + "StepDetails": { + "Attempt": 2, + "NextAttemptTimestamp": 1769481369631, + "Result": null, + "Error": { + "ErrorMessage": "temporary", + "ErrorType": "NetworkError", + "ErrorData": "retryable", + "StackTrace": ["frame 1", "frame 2"], + "FutureErrorField": {"ignored": true} + } + } + }, + { + "Id": "future-1", + "Type": "DISTRIBUTED_STEP", + "Status": "PAUSED_BY_POLICY", + "SubType": "VendorOperation", + "WaitDetails": {"ScheduledEndTimestamp": null} + } + ] + } + })JSON"; + + const auto decoded = durable::decode_invocation_input(json); + CHECK(decoded.has_value()); + if (!decoded) return; + + CHECK(decoded->checkpoint_token == "token-0"); + CHECK( + decoded->updated_operation_ids == + std::vector({"step-1", "future-1"})); + CHECK(decoded->initial_state.next_marker == "marker-1"); + CHECK(decoded->initial_state.operations.size() == 3); + + const auto& root = decoded->initial_state.operations[0]; + CHECK(root.type == durable::operation_type::execution); + CHECK(root.status == durable::operation_status::started); + CHECK(root.execution && root.execution->input_payload == + R"({"order_id":"order-123"})"); + CHECK( + std::chrono::duration_cast( + root.start_timestamp->time_since_epoch()) + .count() == 1769481309631); + + const auto& step = decoded->initial_state.operations[1]; + CHECK(step.name == std::optional{"approval 🚀"}); + CHECK(step.step && step.step->attempt == 2); + CHECK(step.step && step.step->error && + step.step->error->stack_trace.size() == 2); + + const auto& future = decoded->initial_state.operations[2]; + CHECK(!future.type.is_known()); + CHECK(future.type.wire_value() == "DISTRIBUTED_STEP"); + CHECK(!future.status.is_known()); + CHECK(future.status.wire_value() == "PAUSED_BY_POLICY"); +} + +void test_python_cross_language_fixtures() { + const auto invocation = durable::decode_invocation_input( + read_fixture("tests/fixtures/python_invocation.json")); + CHECK(invocation); + if (invocation) { + CHECK(invocation->checkpoint_token == "fixture-token-7"); + CHECK(invocation->initial_state.next_marker == "next-page"); + CHECK(invocation->initial_state.operations.size() == 6); + const auto& step = invocation->initial_state.operations[1]; + CHECK(step.type == durable::operation_type::step); + CHECK(step.status == durable::operation_status::pending); + CHECK(step.step && step.step->attempt == 2); + CHECK(step.step && step.step->result == "2"); + const auto& context = invocation->initial_state.operations[5]; + CHECK(context.context && context.context->replay_children); + } + + std::istringstream id_rows{ + read_fixture("tests/fixtures/python_operation_ids.tsv")}; + std::string line; + while (std::getline(id_rows, line)) { + std::array columns; + std::size_t start = 0; + for (std::size_t column = 0; column < columns.size(); ++column) { + const auto tab = line.find('\t', start); + const auto end = + tab == std::string::npos ? line.size() : tab; + columns[column] = line.substr(start, end - start); + start = end + 1U; + } + durable::operation_id_generator generator{ + columns[1].empty() + ? std::nullopt + : std::optional{columns[1]}}; + const auto actual = + columns[0] == "local" + ? generator.reserve(columns[2]) + : generator.next(); + CHECK(actual == columns[3]); + } + + std::unordered_map payloads; + std::istringstream serdes_rows{ + read_fixture("tests/fixtures/python_serdes.tsv")}; + while (std::getline(serdes_rows, line)) { + const auto tab = line.find('\t'); + CHECK(tab != std::string::npos); + if (tab != std::string::npos) { + payloads.emplace( + line.substr(0, tab), line.substr(tab + 1U)); + } + } + const durable::serdes_context context{}; + CHECK( + durable::default_serdes{}.serialize({}, context) == + payloads["null"]); + CHECK( + durable::default_serdes{}.serialize(true, context) == + payloads["bool_true"]); + CHECK( + durable::default_serdes{}.serialize(-42, context) == + payloads["int_negative"]); + CHECK( + durable::default_serdes{}.serialize(3.25, context) == + payloads["float"]); + CHECK( + durable::default_serdes{}.serialize( + "hello\nworld", context) == payloads["string"]); + + const auto fixture_uuid = durable::uuid_value::parse( + "12345678-1234-4abc-8def-1234567890ab"); + CHECK( + durable::uuid_serdes{}.serialize(fixture_uuid, context) == + payloads["uuid"]); + CHECK( + durable::uuid_serdes{}.deserialize(payloads["uuid"], context) == + fixture_uuid); + + const auto fixture_time = + std::chrono::sys_days{ + std::chrono::year{2026} / std::chrono::August / 26} + + 12h + 34min + 56s + 789ms; + CHECK( + durable::timestamp_serdes{}.serialize(fixture_time, context) == + payloads["datetime"]); + CHECK( + durable::timestamp_serdes{}.deserialize( + payloads["datetime"], context) == fixture_time); +} + +void test_invocation_output_wire_codec() { + durable::invocation_output output{ + .status = durable::invocation_status::failed, + .result = std::string{R"({"partial":true})"}, + .error = durable::error_object{ + .message = "line 1\n\"quoted\"", + .type = "ExampleError", + .data = "metadata", + .stack_trace = {"frame 1", "frame 2"}, + }, + }; + + const auto encoded = durable::encode_invocation_output(output); + CHECK( + encoded == + R"({"Status":"FAILED","Result":"{\"partial\":true}","Error":{"ErrorMessage":"line 1\n\"quoted\"","ErrorType":"ExampleError","ErrorData":"metadata","StackTrace":["frame 1","frame 2"]}})"); + + const auto decoded = durable::decode_invocation_output(encoded); + CHECK(decoded.has_value()); + if (!decoded) return; + CHECK(decoded->status == durable::invocation_status::failed); + CHECK(decoded->result == output.result); + CHECK(decoded->error && decoded->error->message == output.error->message); + CHECK(decoded->error && decoded->error->stack_trace == output.error->stack_trace); +} + +void test_wire_codec_validation() { + const auto missing_token = durable::decode_invocation_input( + R"({"DurableExecutionArn":"arn/example"})"); + CHECK(!missing_token); + CHECK(missing_token.error().message.find("CheckpointToken") != + std::string::npos); + + const auto missing_operation_status = durable::decode_invocation_input( + R"({"DurableExecutionArn":"arn/example","CheckpointToken":"token","InitialExecutionState":{"Operations":[{"Id":"1","Type":"STEP"}]}})"); + CHECK(!missing_operation_status); + CHECK(missing_operation_status.error().message.find("Status") != + std::string::npos); + + const auto invalid_surrogate = durable::decode_invocation_input( + R"({"DurableExecutionArn":"\uD800","CheckpointToken":"token"})"); + CHECK(!invalid_surrogate); + + const auto empty_operation_id = durable::decode_invocation_input( + R"({"DurableExecutionArn":"arn/example","CheckpointToken":"token","InitialExecutionState":{"Operations":[{"Id":"","Type":"STEP","Status":"STARTED"}]}})"); + CHECK(!empty_operation_id); + + const auto level = durable::json_object_integer_field( + R"({"value":1,"__recursive_level":4,"nested":{"x":2}})", + durable::recursive_level_field); + CHECK(level && *level == std::optional{4}); + const auto updated = durable::set_json_object_integer_field( + R"({"__recursive_level":4,"value":1,"nested":{"x":2}})", + durable::recursive_level_field, 5); + CHECK(updated); + CHECK( + updated == + R"({"value":1,"nested":{"x":2},"__recursive_level":5})"); +} + +void test_json_runtime_adapter() { + constexpr std::string_view event = R"JSON({ + "DurableExecutionArn": + "arn:aws:lambda:us-east-1:123456789012:function:orders/execution-1", + "CheckpointToken": "token-0", + "InitialExecutionState": { + "Operations": [{ + "Id": "execution-1", + "Type": "EXECUTION", + "Status": "STARTED", + "SubType": "Execution", + "ExecutionDetails": {"InputPayload": "{\"value\":7}"} + }] + } + })JSON"; + + memory_service_client service; + const auto response = durable::run_json(event, service, [](std::string_view input) { + CHECK(input == R"({"value":7})"); + return durable::step([] { return 42; }); + }); + CHECK(response.has_value()); + CHECK(response == R"({"Status":"SUCCEEDED","Result":"42"})"); + CHECK(service.checkpoint_calls == 2); +} + +void test_step_success_and_replay() { + memory_service_client service; + int executions = 0; + const auto output = durable::run( + input_with(), service, + [&](std::string_view input) { + CHECK(input == R"({"order_id":"order-123"})"); + return durable::step( + [&] { + ++executions; + return 42; + }, + durable::step_config{.name = "calculate"}); + }); + + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(output.result == std::optional{"42"}); + CHECK(executions == 1); + CHECK(service.checkpoint_calls == 2); + + durable::operation replayed{ + .operation_id = first_operation_id(), + .type = durable::operation_type::step, + .status = durable::operation_status::succeeded, + .name = "calculate", + .sub_type = std::string{durable::operation_subtype::step}, + .step = durable::step_details{.result = "42"}, + }; + memory_service_client replay_service; + const auto replay_output = durable::run( + input_with({std::move(replayed)}), replay_service, + [&] { + return durable::step( + [&] { + ++executions; + return 99; + }, + durable::step_config{.name = "calculate"}); + }); + CHECK(replay_output.status == durable::invocation_status::succeeded); + CHECK(replay_output.result == std::optional{"42"}); + CHECK(executions == 1); + CHECK(replay_service.checkpoint_calls == 0); +} + +void test_wait_suspends_and_replays() { + memory_service_client service; + const auto pending = durable::run(input_with(), service, [] { + durable::wait(5s, "approval"); + return std::string{"done"}; + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(service.checkpoint_calls == 1); + + durable::operation completed_wait{ + .operation_id = first_operation_id(), + .type = durable::operation_type::wait, + .status = durable::operation_status::succeeded, + .name = "approval", + .sub_type = std::string{durable::operation_subtype::wait}, + .wait = durable::wait_details{}, + }; + memory_service_client replay_service; + const auto succeeded = durable::run( + input_with({std::move(completed_wait)}), replay_service, [] { + durable::wait(5s, "approval"); + return std::string{"done"}; + }); + CHECK(succeeded.status == durable::invocation_status::succeeded); + CHECK(succeeded.result == std::optional{R"("done")"}); + CHECK(replay_service.checkpoint_calls == 0); +} + +void test_retry_and_transport_failures() { + memory_service_client service; + durable::retry_strategy retry{ + .max_attempts = 2, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }; + const auto pending = durable::run(input_with(), service, [&] { + return durable::step( + []() -> int { throw std::runtime_error{"temporary"}; }, + durable::step_config{ + .name = "unstable", + .retry = retry, + }); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(service.checkpoint_calls == 2); + + memory_service_client failed_service; + failed_service.checkpoint_failure = + durable::service_error{.message = "network unavailable", .retryable = true}; + const auto failed = + durable::run(input_with(), failed_service, [] { return durable::step([] { + return 7; + }); }); + CHECK(failed.status == durable::invocation_status::retry); + CHECK(failed.error && failed.error->message == "network unavailable"); +} + +void test_step_attempt_and_retry_filter() { + std::vector attempts; + auto runner = durable::make_local_runner([&] { + return durable::step( + [&](std::uint32_t attempt) { + attempts.push_back(attempt); + if (attempt < 3U) { + throw std::runtime_error{"transient"}; + } + return 42; + }, + durable::step_config{ + .name = "attempt-aware", + .retry = durable::retry_strategy{ + .max_attempts = 4, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + .retry_decider = + [](const std::exception& error, std::uint32_t attempt) { + if (std::string_view{error.what()} != "transient" || + attempt >= 4U) { + return std::optional{}; + } + return std::optional{1s}; + }, + }); + }); + const auto result = runner.run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.deserialize_result() == 42); + CHECK( + attempts == + std::vector({1U, 2U, 3U})); + + auto non_retryable = durable::make_local_runner([] { + return durable::step( + []() -> int { throw std::logic_error{"permanent"}; }, + durable::step_config{ + .name = "filtered", + .retry_decider = + [](const std::exception& error, std::uint32_t) { + return dynamic_cast(&error) + ? std::optional{1s} + : std::optional{}; + }, + }); + }); + const auto failed = non_retryable.run(); + CHECK(failed.status() == durable::local_run_status::failed); + CHECK(failed.invocation_count() == 1); +} + +void test_at_most_once_interruption() { + durable::operation interrupted{ + .operation_id = first_operation_id(), + .type = durable::operation_type::step, + .status = durable::operation_status::started, + .name = "charge", + .sub_type = std::string{durable::operation_subtype::step}, + .step = durable::step_details{}, + }; + memory_service_client service; + const auto output = durable::run( + input_with({std::move(interrupted)}), service, [] { + return durable::step( + [] { return 1; }, + durable::step_config{ + .name = "charge", + .retry = durable::retry_strategy::none(), + .semantics = + durable::step_semantics::at_most_once_per_retry, + }); + }); + CHECK(output.status == durable::invocation_status::failed); + CHECK(service.checkpoint_calls == 1); +} + +void test_unknown_future_status_fails_closed() { + durable::operation future_step{ + .operation_id = first_operation_id(), + .type = durable::operation_type::step, + .status = durable::operation_status_from_wire("PAUSED_BY_POLICY"), + .name = "charge", + .sub_type = std::string{durable::operation_subtype::step}, + .step = durable::step_details{}, + }; + memory_service_client service; + bool executed = false; + const auto output = durable::run( + input_with({std::move(future_step)}), service, [&] { + return durable::step( + [&] { + executed = true; + return 1; + }, + durable::step_config{.name = "charge"}); + }); + CHECK(output.status == durable::invocation_status::failed); + CHECK(!executed); + CHECK(service.checkpoint_calls == 0); +} + +void test_unknown_root_status_fails_closed() { + auto input = input_with(); + input.initial_state.operations.front().status = + durable::operation_status_from_wire("FUTURE_EXECUTION_STATE"); + memory_service_client service; + bool executed = false; + const auto output = durable::run(input, service, [&] { + executed = true; + return 1; + }); + CHECK(output.status == durable::invocation_status::failed); + CHECK(!executed); + CHECK(service.checkpoint_calls == 0); +} + +void test_callback_creation_and_replay() { + memory_service_client service; + bool code_after_creation = false; + std::string callback_id; + const auto pending = durable::run(input_with(), service, [&] { + auto callback = durable::create_callback(durable::callback_config{ + .name = "approval", + .timeout = 30s, + .heartbeat_timeout = 5s, + }); + callback_id = callback.callback_id(); + code_after_creation = true; + const auto result = callback.result(); + return result.value_or("missing"); + }); + + CHECK(pending.status == durable::invocation_status::pending); + CHECK(code_after_creation); + CHECK(!callback_id.empty()); + CHECK(service.checkpoint_calls == 1); + CHECK(service.received_updates.size() == 1); + CHECK( + service.received_updates[0].type == + durable::operation_type::callback); + CHECK(service.received_updates[0].callback && + service.received_updates[0].callback->timeout_seconds == 30); + CHECK(service.received_updates[0].callback && + service.received_updates[0].callback->heartbeat_timeout_seconds == 5); + + durable::operation completed{ + .operation_id = first_operation_id(), + .type = durable::operation_type::callback, + .status = durable::operation_status::succeeded, + .name = "approval", + .sub_type = std::string{durable::operation_subtype::callback}, + .callback = durable::callback_details{ + .callback_id = "callback-123", + .result = "approved", + }, + }; + memory_service_client replay_service; + const auto replayed = durable::run( + input_with({std::move(completed)}), replay_service, [] { + auto callback = durable::create_callback( + durable::callback_config{.name = "approval"}); + return callback.result().value_or("missing"); + }); + CHECK(replayed.status == durable::invocation_status::succeeded); + CHECK(replayed.result == std::optional{R"("approved")"}); + CHECK(replay_service.checkpoint_calls == 0); +} + +void test_callback_failure_is_deferred_to_result() { + durable::operation failed_callback{ + .operation_id = first_operation_id(), + .type = durable::operation_type::callback, + .status = durable::operation_status::timed_out, + .name = "approval", + .sub_type = std::string{durable::operation_subtype::callback}, + .callback = durable::callback_details{ + .callback_id = "callback-timeout", + .error = durable::error_object{ + .message = "Approval expired", + .type = "Callback.Timeout", + }, + }, + }; + + bool code_after_creation = false; + memory_service_client create_service; + const auto creation = durable::run( + input_with({failed_callback}), create_service, [&] { + auto callback = durable::create_callback( + durable::callback_config{.name = "approval"}); + code_after_creation = true; + return callback.callback_id(); + }); + CHECK(creation.status == durable::invocation_status::succeeded); + CHECK(code_after_creation); + CHECK(creation.result == + std::optional{R"("callback-timeout")"}); + + memory_service_client result_service; + const auto result = durable::run( + input_with({std::move(failed_callback)}), result_service, [] { + auto callback = durable::create_callback( + durable::callback_config{.name = "approval"}); + return callback.result().value_or("missing"); + }); + CHECK(result.status == durable::invocation_status::failed); + CHECK(result.error && result.error->message && + result.error->message->find("Callback.Timeout") != std::string::npos); +} + +void test_chained_invoke_start_and_replay() { + memory_service_client service; + const auto pending = durable::run(input_with(), service, [] { + return durable::invoke( + "orders-worker:prod", std::string{"order-123"}, + durable::invoke_config{ + .name = "process-order", + .tenant_id = "tenant-7", + }) + .value_or("missing"); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(service.checkpoint_calls == 1); + CHECK(service.received_updates.size() == 1); + const auto& update = service.received_updates[0]; + CHECK(update.type == durable::operation_type::chained_invoke); + CHECK(update.payload == std::optional{R"("order-123")"}); + CHECK(update.chained_invoke && + update.chained_invoke->function_name == "orders-worker:prod"); + CHECK(update.chained_invoke && + update.chained_invoke->tenant_id == + std::optional{"tenant-7"}); + + durable::operation completed{ + .operation_id = first_operation_id(), + .type = durable::operation_type::chained_invoke, + .status = durable::operation_status::succeeded, + .name = "process-order", + .sub_type = std::string{durable::operation_subtype::chained_invoke}, + .chained_invoke = durable::chained_invoke_details{ + .result = R"("done")", + }, + }; + memory_service_client replay_service; + const auto replayed = durable::run( + input_with({std::move(completed)}), replay_service, [] { + return durable::invoke( + "orders-worker:prod", std::string{"order-123"}, + durable::invoke_config{.name = "process-order"}) + .value_or("missing"); + }); + CHECK(replayed.status == durable::invocation_status::succeeded); + CHECK(replayed.result == std::optional{R"("done")"}); + CHECK(replay_service.checkpoint_calls == 0); +} + +void test_chained_invoke_failure() { + durable::operation failed{ + .operation_id = first_operation_id(), + .type = durable::operation_type::chained_invoke, + .status = durable::operation_status::failed, + .name = "process-order", + .sub_type = std::string{durable::operation_subtype::chained_invoke}, + .chained_invoke = durable::chained_invoke_details{ + .error = durable::error_object{ + .message = "Downstream rejected order", + .type = "OrderRejected", + }, + }, + }; + memory_service_client service; + const auto output = durable::run( + input_with({std::move(failed)}), service, [] { + return durable::invoke( + "orders-worker:prod", std::string{"order-123"}, + durable::invoke_config{.name = "process-order"}) + .value_or("missing"); + }); + CHECK(output.status == durable::invocation_status::failed); + CHECK(output.error && output.error->message == "Downstream rejected order"); +} + +void test_child_context_lifecycle_and_replay() { + memory_service_client service; + const auto output = durable::run(input_with(), service, [] { + return durable::run_in_child_context( + [] { + return durable::step( + [] { return 7; }, + durable::step_config{.name = "inner-step"}) + + 1; + }, + durable::child_context_config{.name = "child"}); + }); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(output.result == std::optional{"8"}); + CHECK(service.checkpoint_calls == 4); + CHECK(service.received_updates.size() == 4); + CHECK(service.received_updates[0].type == + durable::operation_type::context); + CHECK(service.received_updates[1].type == durable::operation_type::step); + CHECK(service.received_updates[2].action == + durable::operation_action::succeed); + CHECK(service.received_updates[3].type == + durable::operation_type::context); + + const std::string child_id = first_operation_id(); + durable::operation_id_generator child_ids{child_id}; + CHECK(service.received_updates[1].operation_id == child_ids.next()); + CHECK(service.received_updates[1].parent_id == + std::optional{child_id}); + + durable::operation completed{ + .operation_id = child_id, + .type = durable::operation_type::context, + .status = durable::operation_status::succeeded, + .name = "child", + .sub_type = + std::string{durable::operation_subtype::run_in_child_context}, + .context = durable::context_details{ + .result = "8", + }, + }; + int executions = 0; + memory_service_client replay_service; + const auto replayed = durable::run( + input_with({std::move(completed)}), replay_service, [&] { + return durable::run_in_child_context( + [&] { + ++executions; + return 99; + }, + durable::child_context_config{.name = "child"}); + }); + CHECK(replayed.status == durable::invocation_status::succeeded); + CHECK(replayed.result == std::optional{"8"}); + CHECK(executions == 0); + CHECK(replay_service.checkpoint_calls == 0); +} + +void test_child_context_suspension_and_failure() { + memory_service_client wait_service; + const auto pending = durable::run(input_with(), wait_service, [] { + return durable::run_in_child_context( + [] { + durable::wait(5s, "inner-wait"); + return 1; + }, + durable::child_context_config{.name = "child"}); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(wait_service.checkpoint_calls == 2); + CHECK(wait_service.received_updates.size() == 2); + CHECK(wait_service.received_updates[0].action == + durable::operation_action::start); + CHECK(wait_service.received_updates[1].type == + durable::operation_type::wait); + + memory_service_client failure_service; + const auto failed = durable::run(input_with(), failure_service, [] { + return durable::run_in_child_context( + []() -> int { throw std::runtime_error{"child failed"}; }, + durable::child_context_config{.name = "child"}); + }); + CHECK(failed.status == durable::invocation_status::failed); + CHECK(failure_service.checkpoint_calls == 2); + CHECK(failure_service.received_updates.back().action == + durable::operation_action::fail); +} + +void test_child_context_replays_prefixed_history() { + const std::string child_id = first_operation_id(); + durable::operation_id_generator child_ids{child_id}; + const std::string inner_id = child_ids.next(); + + durable::operation child_started{ + .operation_id = child_id, + .type = durable::operation_type::context, + .status = durable::operation_status::started, + .name = "child", + .sub_type = + std::string{durable::operation_subtype::run_in_child_context}, + .context = durable::context_details{}, + }; + durable::operation inner_completed{ + .operation_id = inner_id, + .type = durable::operation_type::step, + .status = durable::operation_status::succeeded, + .parent_id = child_id, + .name = "inner-step", + .sub_type = std::string{durable::operation_subtype::step}, + .step = durable::step_details{.result = "7"}, + }; + + int executions = 0; + memory_service_client service; + const auto output = durable::run( + input_with({std::move(child_started), std::move(inner_completed)}), + service, [&] { + return durable::run_in_child_context( + [&] { + return durable::step( + [&] { + ++executions; + return 99; + }, + durable::step_config{.name = "inner-step"}) + + 1; + }, + durable::child_context_config{.name = "child"}); + }); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(output.result == std::optional{"8"}); + CHECK(executions == 0); + CHECK(service.checkpoint_calls == 1); + CHECK(service.received_updates[0].operation_id == child_id); + CHECK(service.received_updates[0].action == + durable::operation_action::succeed); +} + +void test_child_context_replay_children_and_virtual_mode() { + std::string large_value(300'000, 'x'); + memory_service_client large_service; + const auto large = durable::run(input_with(), large_service, [&] { + const auto result = durable::run_in_child_context( + [&] { return large_value; }, durable::default_serdes{}, + [](const std::string& value) { + return std::string{"{\"size\":"} + + std::to_string(value.size()) + "}"; + }, + durable::child_context_config{.name = "large-child"}); + return static_cast(result.size()); + }); + CHECK(large.status == durable::invocation_status::succeeded); + CHECK(large.result == std::optional{"300000"}); + CHECK(large_service.received_updates.size() == 2); + const auto& completion = large_service.received_updates.back(); + CHECK(completion.context && completion.context->replay_children); + CHECK(completion.payload == + std::optional{R"({"size":300000})"}); + + durable::operation replay_parent{ + .operation_id = first_operation_id(), + .type = durable::operation_type::context, + .status = durable::operation_status::succeeded, + .name = "large-child", + .sub_type = + std::string{durable::operation_subtype::run_in_child_context}, + .context = durable::context_details{ + .replay_children = true, + .result = R"({"size":300000})", + }, + }; + int replay_executions = 0; + memory_service_client replay_service; + const auto replayed = durable::run( + input_with({std::move(replay_parent)}), replay_service, [&] { + return durable::run_in_child_context( + [&] { + ++replay_executions; + return 12; + }, + durable::child_context_config{.name = "large-child"}); + }); + CHECK(replayed.status == durable::invocation_status::succeeded); + CHECK(replayed.result == std::optional{"12"}); + CHECK(replay_executions == 1); + CHECK(replay_service.checkpoint_calls == 0); + + memory_service_client virtual_service; + const auto virtual_result = durable::run(input_with(), virtual_service, [] { + return durable::run_in_child_context( + [] { return durable::step([] { return 5; }); }, + durable::child_context_config{ + .name = "virtual-child", + .is_virtual = true, + }); + }); + CHECK(virtual_result.status == durable::invocation_status::succeeded); + CHECK(virtual_result.result == std::optional{"5"}); + CHECK(virtual_service.checkpoint_calls == 2); + CHECK(virtual_service.received_updates.size() == 2); + CHECK(virtual_service.received_updates[0].type == + durable::operation_type::step); +} + +void test_wait_for_callback_composition() { + std::string submitted_callback_id; + int submitter_executions = 0; + memory_service_client service; + const auto pending = durable::run(input_with(), service, [&] { + return durable::wait_for_callback( + [&](std::string_view callback_id) { + ++submitter_executions; + submitted_callback_id = callback_id; + }, + durable::wait_for_callback_config{ + .name = "approval", + .timeout = 60s, + .heartbeat_timeout = 10s, + .submitter_retry = durable::retry_strategy::none(), + }) + .value_or("missing"); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(submitter_executions == 1); + CHECK(!submitted_callback_id.empty()); + CHECK(service.checkpoint_calls == 4); + CHECK(service.received_updates.size() == 4); + CHECK(service.received_updates[0].type == + durable::operation_type::context); + CHECK(service.received_updates[1].type == + durable::operation_type::callback); + CHECK(service.received_updates[2].type == + durable::operation_type::step); + CHECK(service.received_updates[3].action == + durable::operation_action::succeed); + + const std::string context_id = first_operation_id(); + durable::operation_id_generator child_ids{context_id}; + const std::string callback_operation_id = child_ids.next(); + const std::string submitter_operation_id = child_ids.next(); + + durable::operation context_started{ + .operation_id = context_id, + .type = durable::operation_type::context, + .status = durable::operation_status::started, + .name = "approval", + .sub_type = + std::string{durable::operation_subtype::wait_for_callback}, + .context = durable::context_details{}, + }; + durable::operation callback_completed{ + .operation_id = callback_operation_id, + .type = durable::operation_type::callback, + .status = durable::operation_status::succeeded, + .parent_id = context_id, + .name = "approval-callback", + .sub_type = std::string{durable::operation_subtype::callback}, + .callback = durable::callback_details{ + .callback_id = "callback-external", + .result = "approved", + }, + }; + durable::operation submitter_completed{ + .operation_id = submitter_operation_id, + .type = durable::operation_type::step, + .status = durable::operation_status::succeeded, + .parent_id = context_id, + .name = "approval-submitter", + .sub_type = std::string{durable::operation_subtype::step}, + .step = durable::step_details{.result = "null"}, + }; + + memory_service_client replay_service; + const auto completed = durable::run( + input_with( + {std::move(context_started), std::move(callback_completed), + std::move(submitter_completed)}), + replay_service, [&] { + return durable::wait_for_callback( + [&](std::string_view) { ++submitter_executions; }, + durable::wait_for_callback_config{ + .name = "approval", + .submitter_retry = + durable::retry_strategy::none(), + }) + .value_or("missing"); + }); + CHECK(completed.status == durable::invocation_status::succeeded); + CHECK(completed.result == + std::optional{R"("approved")"}); + CHECK(submitter_executions == 1); + CHECK(replay_service.checkpoint_calls == 1); + CHECK(replay_service.received_updates[0].type == + durable::operation_type::context); + CHECK(replay_service.received_updates[0].payload == + std::optional{ + R"({"p":true,"v":"approved"})"}); +} + +void test_concurrent_checkpoint_batching() { + constexpr std::size_t operation_count = 16; + memory_service_client service; + const auto input = input_with(); + durable::execution_state state{ + input.durable_execution_arn, input.checkpoint_token, service, + durable::checkpoint_batcher_config{ + .max_batch_size_bytes = 750U * 1024U, + .max_batch_operations = 250, + .coalescing_yields = 0, + .coalescing_delay = 5ms, + }}; + state.initialize(input.initial_state); + + std::atomic thread_failures{0}; + { + auto batching = state.enable_checkpoint_batching(); + std::barrier start_line{static_cast(operation_count)}; + std::vector workers; + workers.reserve(operation_count); + for (std::size_t index = 0; index < operation_count; ++index) { + workers.emplace_back([&, index] { + start_line.arrive_and_wait(); + try { + durable::operation_identifier identifier{ + .operation_id = "batch-" + std::to_string(index), + .sub_type = std::string{durable::operation_subtype::step}, + .parent_id = std::nullopt, + .name = "batched-step-" + std::to_string(index), + .type = durable::operation_type::step, + }; + state.checkpoint(durable::operation_update::step_start(identifier)); + } catch (...) { + thread_failures.fetch_add(1, std::memory_order_relaxed); + } + }); + } + workers.clear(); + } + + CHECK(thread_failures.load(std::memory_order_relaxed) == 0); + CHECK(service.checkpoint_calls == 1); + CHECK(service.checkpoint_batch_sizes == + std::vector{operation_count}); + CHECK(state.operation_count() == operation_count + 1U); + CHECK(state.checkpoint_token() == "token-1"); + for (std::size_t index = 0; index < operation_count; ++index) { + const auto operation = + state.find_operation("batch-" + std::to_string(index)); + CHECK(operation && + operation->status == durable::operation_status::started); + } + + durable::operation_identifier direct_identifier{ + .operation_id = "direct-after-batch", + .sub_type = std::string{durable::operation_subtype::step}, + .parent_id = std::nullopt, + .name = "direct", + .type = durable::operation_type::step, + }; + state.checkpoint( + durable::operation_update::step_start(direct_identifier)); + CHECK(service.checkpoint_calls == 2); + CHECK(service.checkpoint_batch_sizes.back() == 1); + CHECK(state.checkpoint_token() == "token-2"); +} + +void test_checkpoint_batch_limits() { + constexpr std::size_t operation_count = 10; + memory_service_client service; + const auto input = input_with(); + durable::execution_state state{ + input.durable_execution_arn, input.checkpoint_token, service, + durable::checkpoint_batcher_config{ + .max_batch_size_bytes = 750U * 1024U, + .max_batch_operations = 4, + .coalescing_yields = 0, + .coalescing_delay = 5ms, + }}; + state.initialize(input.initial_state); + + { + auto batching = state.enable_checkpoint_batching(); + std::barrier start_line{static_cast(operation_count)}; + std::vector workers; + workers.reserve(operation_count); + for (std::size_t index = 0; index < operation_count; ++index) { + workers.emplace_back([&, index] { + start_line.arrive_and_wait(); + durable::operation_identifier identifier{ + .operation_id = "limited-" + std::to_string(index), + .sub_type = std::string{durable::operation_subtype::step}, + .parent_id = std::nullopt, + .name = std::nullopt, + .type = durable::operation_type::step, + }; + state.checkpoint( + durable::operation_update::step_start(identifier)); + }); + } + workers.clear(); + } + + std::size_t checkpointed_operations = 0; + for (const auto size : service.checkpoint_batch_sizes) { + CHECK(size <= 4); + checkpointed_operations += size; + } + CHECK(checkpointed_operations == operation_count); + CHECK(service.checkpoint_calls == 3); +} + +void test_parallel_execution_and_replay() { + std::atomic active{0}; + std::atomic max_active{0}; + std::atomic executions{0}; + auto make_branch = [&](int value) { + return [&, value] { + return durable::step( + [&, value] { + ++executions; + const int current = + active.fetch_add(1, std::memory_order_acq_rel) + 1; + int observed = max_active.load(std::memory_order_relaxed); + while (observed < current && + !max_active.compare_exchange_weak( + observed, current, std::memory_order_relaxed)) { + } + std::this_thread::sleep_for(10ms); + active.fetch_sub(1, std::memory_order_acq_rel); + return value; + }, + durable::step_config{ + .name = "branch-step-" + std::to_string(value)}); + }; + }; + + memory_service_client service; + const auto output = durable::run(input_with(), service, [&] { + auto result = durable::parallel( + std::tuple{make_branch(2), make_branch(3)}, + durable::parallel_config{ + .name = "parallel-test", + .max_concurrency = 2, + }); + const auto values = result.results(); + return std::accumulate(values.begin(), values.end(), 0); + }); + CHECK(output.status == durable::invocation_status::succeeded); + if (output.result != std::optional{"5"}) { + std::cerr << "parallel output result=" + << (output.result ? *output.result : "") << '\n'; + } + CHECK(output.result == std::optional{"5"}); + CHECK(executions.load(std::memory_order_relaxed) == 2); + CHECK(max_active.load(std::memory_order_relaxed) == 2); + if (service.checkpoint_calls >= 8) { + std::cerr << "parallel checkpoint calls=" << service.checkpoint_calls + << " batch sizes:"; + for (const auto size : service.checkpoint_batch_sizes) { + std::cerr << ' ' << size; + } + std::cerr << '\n'; + } + CHECK(service.checkpoint_calls < 8); + + std::vector history; + history.reserve(service.operations.size()); + for (const auto& [id, operation] : service.operations) { + (void)id; + history.push_back(operation); + } + memory_service_client replay_service; + const auto replayed = durable::run( + input_with(std::move(history)), replay_service, [&] { + auto result = durable::parallel( + std::tuple{make_branch(20), make_branch(30)}, + durable::parallel_config{ + .name = "parallel-test", + .max_concurrency = 2, + }); + const auto values = result.results(); + return std::accumulate(values.begin(), values.end(), 0); + }); + CHECK(replayed.status == durable::invocation_status::succeeded); + if (replayed.result != std::optional{"5"}) { + std::cerr << "parallel replay result=" + << (replayed.result ? *replayed.result : "") << '\n'; + } + CHECK(replayed.result == std::optional{"5"}); + CHECK(executions.load(std::memory_order_relaxed) == 2); + CHECK(replay_service.checkpoint_calls == 0); +} + +void test_parallel_failure_and_suspension() { + memory_service_client failure_service; + const auto failure_result = durable::run( + input_with(), failure_service, [] { + auto result = durable::parallel( + std::tuple{ + []() -> int { throw std::runtime_error{"branch failed"}; }, + [] { return 2; }}, + durable::parallel_config{ + .name = "failure-parallel", + .max_concurrency = 1, + }); + CHECK( + result.reason == + durable::completion_reason::failure_tolerance_exceeded); + CHECK(result.failure_count() == 1); + CHECK(result.cancelled_count() == 0); + CHECK(result.total_count() == 1); + return static_cast(result.failure_count()); + }); + CHECK(failure_result.status == durable::invocation_status::succeeded); + CHECK(failure_result.result == std::optional{"1"}); + + memory_service_client wait_service; + const auto pending = durable::run(input_with(), wait_service, [] { + auto result = durable::parallel( + std::tuple{ + [] { + durable::wait(5s, "branch-wait"); + return 1; + }, + [] { return durable::step([] { return 2; }); }}, + durable::parallel_config{ + .name = "wait-parallel", + .max_concurrency = 2, + }); + return static_cast(result.success_count()); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(std::ranges::none_of( + wait_service.received_updates, [](const auto& update) { + return update.type == durable::operation_type::context && + update.action == durable::operation_action::fail && + update.name == + std::optional{"wait-parallel"}; + })); +} + +void test_durable_map_completion_threshold() { + const std::array items{1, 2, 3, 4}; + std::atomic executions{0}; + memory_service_client service; + const auto output = durable::run(input_with(), service, [&] { + auto result = durable::map( + [&](const int& value) { + ++executions; + return durable::step([value] { return value * value; }); + }, + items, + durable::map_config{ + .name = "square-map", + .max_concurrency = 1, + .completion = durable::completion_config::thresholds( + 2U, std::numeric_limits::max()), + }); + CHECK( + result.reason == + durable::completion_reason::min_successful_reached); + CHECK(result.success_count() == 2); + CHECK(result.cancelled_count() == 0); + CHECK(result.total_count() == 2); + const auto values = result.results(); + return std::accumulate(values.begin(), values.end(), 0); + }); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(output.result == std::optional{"5"}); + CHECK(executions.load(std::memory_order_relaxed) == 2); +} + +void test_parallel_void_and_flat_nesting() { + memory_service_client void_service; + const auto void_output = durable::run(input_with(), void_service, [] { + auto result = durable::parallel( + std::tuple{[] {}, [] {}}, + durable::parallel_config{ + .name = "void-parallel", + .max_concurrency = 2, + }); + return static_cast(result.success_count()); + }); + CHECK(void_output.status == durable::invocation_status::succeeded); + CHECK(void_output.result == std::optional{"2"}); + + memory_service_client flat_service; + const auto flat_output = durable::run(input_with(), flat_service, [] { + const std::array branches{ + std::function{[] { + return durable::step( + [] { return 9; }, + durable::step_config{.name = "flat-step"}); + }}}; + auto result = durable::parallel( + branches, + durable::parallel_config{ + .name = "flat-parallel", + .max_concurrency = 1, + .nesting = durable::nesting_type::flat, + }); + return result.results().front(); + }); + CHECK(flat_output.status == durable::invocation_status::succeeded); + CHECK(flat_output.result == std::optional{"9"}); + CHECK(std::ranges::none_of( + flat_service.received_updates, [](const auto& update) { + return update.sub_type == + std::optional{ + std::string{durable::operation_subtype::parallel_branch}}; + })); + + const std::string parallel_id = first_operation_id(); + durable::operation_id_generator branch_ids{parallel_id}; + const std::string branch_id = branch_ids.next(); + durable::operation_id_generator branch_operations{branch_id}; + const std::string step_id = branch_operations.next(); + const auto step_update = std::ranges::find_if( + flat_service.received_updates, [&](const auto& update) { + return update.operation_id == step_id; + }); + CHECK(step_update != flat_service.received_updates.end()); + if (step_update != flat_service.received_updates.end()) { + CHECK(step_update->parent_id == + std::optional{parallel_id}); + } +} + +void test_parallel_percentage_accessors_and_concurrency_validation() { + memory_service_client boundary_service; + const auto boundary = durable::run(input_with(), boundary_service, [] { + auto result = durable::parallel( + std::tuple{ + []() -> int { throw std::runtime_error{"expected"}; }, + [] { return 2; }}, + durable::parallel_config{ + .name = "percentage-boundary", + .max_concurrency = 1, + .completion = durable::completion_config::thresholds( + std::nullopt, std::nullopt, 50.0), + }); + CHECK( + result.reason == durable::completion_reason::all_completed); + CHECK(result.status() == durable::batch_item_status::failed); + CHECK(result.failed().size() == 1); + CHECK(result.succeeded().size() == 1); + CHECK(result.errors().size() == 1); + return static_cast(result.success_count()); + }); + CHECK(boundary.status == durable::invocation_status::succeeded); + CHECK(boundary.result == std::optional{"1"}); + + memory_service_client exceeded_service; + const auto exceeded = durable::run(input_with(), exceeded_service, [] { + auto result = durable::parallel( + std::tuple{ + []() -> int { throw std::runtime_error{"expected"}; }, + [] { return 2; }}, + durable::parallel_config{ + .name = "percentage-exceeded", + .max_concurrency = 1, + .completion = durable::completion_config::thresholds( + std::nullopt, std::nullopt, 25.0), + }); + CHECK( + result.reason == + durable::completion_reason::failure_tolerance_exceeded); + CHECK(result.cancelled_count() == 0); + CHECK(result.total_count() == 1); + return static_cast(result.failure_count()); + }); + CHECK(exceeded.status == durable::invocation_status::succeeded); + CHECK(exceeded.result == std::optional{"1"}); + + memory_service_client invalid_parallel_service; + const auto invalid_parallel = durable::run( + input_with(), invalid_parallel_service, [] { + const std::array branches{std::function{[] { return 1; }}}; + return static_cast( + durable::parallel( + branches, + durable::parallel_config{.max_concurrency = 0}) + .success_count()); + }); + CHECK(invalid_parallel.status == durable::invocation_status::failed); + CHECK(invalid_parallel_service.checkpoint_calls == 0); + + memory_service_client invalid_map_service; + const auto invalid_map = durable::run( + input_with(), invalid_map_service, [] { + const std::array items{1}; + return static_cast( + durable::map( + [](const int& value) { return value; }, items, + durable::map_config{.max_concurrency = 0}) + .success_count()); + }); + CHECK(invalid_map.status == durable::invocation_status::failed); + CHECK(invalid_map_service.checkpoint_calls == 0); +} + +void test_map_item_index_and_naming() { + const std::array items{std::string{"a"}, std::string{"b"}}; + memory_service_client service; + const auto output = durable::run(input_with(), service, [&] { + auto result = durable::map( + [](const std::string& item, std::size_t index) { + return item + std::to_string(index); + }, + items, + durable::map_config{ + .name = "indexed-map", + .max_concurrency = 1, + .item_namer = + [](std::size_t index) { + return "named-item-" + std::to_string(index); + }, + }); + const auto values = result.results(); + return values[0] + "," + values[1]; + }); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(output.result == std::optional{R"("a0,b1")"}); + CHECK(std::ranges::any_of( + service.received_updates, [](const auto& update) { + return update.name == + std::optional{"named-item-0"}; + })); + CHECK(std::ranges::any_of( + service.received_updates, [](const auto& update) { + return update.name == + std::optional{"named-item-1"}; + })); +} + +void test_replay_safe_values() { + double first_random = 0.0; + durable::timestamp first_now{}; + double first_timestamp = 0.0; + durable::uuid_value first_uuid; + + memory_service_client service; + const auto output = durable::run(input_with(), service, [&] { + first_random = durable::replay_safe::random(); + first_now = durable::replay_safe::now(); + first_timestamp = durable::replay_safe::timestamp_seconds(); + first_uuid = durable::replay_safe::uuid(); + return 1; + }); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(first_random >= 0.0 && first_random < 1.0); + CHECK( + std::chrono::duration_cast( + first_now.time_since_epoch()) + .count() % + 1'000'000 == + 0); + const auto uuid_text = first_uuid.to_string(); + CHECK(uuid_text.size() == 36); + CHECK(uuid_text[14] == '4'); + CHECK( + uuid_text[19] == '8' || uuid_text[19] == '9' || + uuid_text[19] == 'a' || uuid_text[19] == 'b'); + + std::vector history; + history.reserve(service.operations.size()); + for (const auto& [id, operation] : service.operations) { + (void)id; + history.push_back(operation); + } + + double replay_random = 0.0; + durable::timestamp replay_now{}; + double replay_timestamp = 0.0; + durable::uuid_value replay_uuid; + memory_service_client replay_service; + const auto replayed = durable::run( + input_with(std::move(history)), replay_service, [&] { + replay_random = durable::replay_safe::random(); + replay_now = durable::replay_safe::now(); + replay_timestamp = + durable::replay_safe::timestamp_seconds(); + replay_uuid = durable::replay_safe::uuid(); + return 1; + }); + CHECK(replayed.status == durable::invocation_status::succeeded); + CHECK(replay_random == first_random); + CHECK(replay_now == first_now); + CHECK(replay_timestamp == first_timestamp); + CHECK(replay_uuid == first_uuid); + CHECK(replay_service.checkpoint_calls == 0); + CHECK(durable::uuid_value::parse(uuid_text) == first_uuid); +} + +void test_wait_for_condition_stateful_polling() { + auto strategy = [](const int& value, std::uint32_t) { + return value >= 3 + ? std::optional{} + : std::optional{1s}; + }; + + int observed_attempt = 0; + memory_service_client service; + const auto pending = durable::run(input_with(), service, [&] { + return durable::wait_for_condition( + [&](const std::optional& state, std::uint32_t attempt) { + observed_attempt = static_cast(attempt); + return state.value_or(0) + 1; + }, + 0, strategy, + durable::wait_for_condition_config{.name = "poll"}); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(observed_attempt == 1); + CHECK(service.checkpoint_calls == 2); + CHECK(service.received_updates.back().action == + durable::operation_action::retry); + CHECK(service.received_updates.back().payload == + std::optional{"1"}); + + durable::operation ready{ + .operation_id = first_operation_id(), + .type = durable::operation_type::step, + .status = durable::operation_status::ready, + .name = "poll", + .sub_type = + std::string{durable::operation_subtype::wait_for_condition}, + .step = durable::step_details{ + .attempt = 1, + .result = "1", + }, + }; + memory_service_client ready_service; + ready_service.drop_step_result_on_start = true; + ready_service.operations.emplace(ready.operation_id, ready); + const auto completed = durable::run( + input_with({ready}), ready_service, [&] { + return durable::wait_for_condition( + [&](const std::optional& state, std::uint32_t attempt) { + observed_attempt = static_cast(attempt); + return state.value_or(0) + 2; + }, + 0, strategy, + durable::wait_for_condition_config{.name = "poll"}); + }); + CHECK(completed.status == durable::invocation_status::succeeded); + CHECK(completed.result == std::optional{"3"}); + CHECK(observed_attempt == 2); + CHECK(ready_service.checkpoint_calls == 2); +} + +void test_wait_for_condition_pending_and_exhaustion() { + durable::operation pending_operation{ + .operation_id = first_operation_id(), + .type = durable::operation_type::step, + .status = durable::operation_status::pending, + .name = "poll", + .sub_type = + std::string{durable::operation_subtype::wait_for_condition}, + .step = durable::step_details{ + .attempt = 1, + .next_attempt_timestamp = + std::chrono::system_clock::now() + 5s, + .result = "1", + }, + }; + bool checked = false; + memory_service_client pending_service; + const auto pending = durable::run( + input_with({std::move(pending_operation)}), pending_service, [&] { + return durable::wait_for_condition( + [&](const std::optional&) { + checked = true; + return 2; + }, + 0, + [](const int&, std::uint32_t) { + return std::optional{1s}; + }, + durable::wait_for_condition_config{.name = "poll"}); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(!checked); + CHECK(pending_service.checkpoint_calls == 0); + + memory_service_client exhausted_service; + const auto exhausted = durable::run(input_with(), exhausted_service, [] { + return durable::wait_for_condition( + [](const std::optional&) { return 0; }, 0, + durable::polling_strategy{ + .max_attempts = 1, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + durable::wait_for_condition_config{.name = "exhausted"}); + }); + CHECK(exhausted.status == durable::invocation_status::failed); + CHECK(exhausted.error && exhausted.error->message && + exhausted.error->message->find("exhausted 1 attempts") != + std::string::npos); + CHECK(exhausted_service.checkpoint_calls == 2); + CHECK(exhausted_service.received_updates.back().action == + durable::operation_action::fail); +} + +void test_with_retry_scope() { + int executions = 0; + memory_service_client service; + const auto pending = durable::run(input_with(), service, [&] { + return durable::with_retry( + [&](std::uint32_t attempt) -> int { + ++executions; + if (attempt == 1U) { + throw std::runtime_error{"try again"}; + } + return 42; + }, + durable::with_retry_config{ + .name = "retry-test", + .retry = durable::retry_strategy{ + .max_attempts = 3, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(executions == 1); + CHECK(service.checkpoint_calls == 2); + CHECK(service.received_updates[1].type == + durable::operation_type::wait); + CHECK(service.received_updates[1].name == + std::optional{"retry-test-backoff-1"}); + + const std::string context_id = first_operation_id(); + durable::operation_id_generator child_ids{context_id}; + const std::string wait_id = child_ids.next(); + durable::operation context_started{ + .operation_id = context_id, + .type = durable::operation_type::context, + .status = durable::operation_status::started, + .name = "retry-test", + .sub_type = + std::string{durable::operation_subtype::run_in_child_context}, + .context = durable::context_details{}, + }; + durable::operation backoff_completed{ + .operation_id = wait_id, + .type = durable::operation_type::wait, + .status = durable::operation_status::succeeded, + .parent_id = context_id, + .name = "retry-test-backoff-1", + .sub_type = std::string{durable::operation_subtype::wait}, + .wait = durable::wait_details{}, + }; + + executions = 0; + memory_service_client replay_service; + const auto completed = durable::run( + input_with( + {std::move(context_started), std::move(backoff_completed)}), + replay_service, [&] { + return durable::with_retry( + [&](std::uint32_t attempt) -> int { + ++executions; + if (attempt == 1U) { + throw std::runtime_error{"try again"}; + } + return 42; + }, + durable::with_retry_config{ + .name = "retry-test", + .retry = durable::retry_strategy{ + .max_attempts = 3, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + }); + CHECK(completed.status == durable::invocation_status::succeeded); + CHECK(completed.result == std::optional{"42"}); + CHECK(executions == 2); + CHECK(replay_service.checkpoint_calls == 1); + + memory_service_client exhausted_service; + const auto exhausted = durable::run(input_with(), exhausted_service, [] { + return durable::with_retry( + []() -> int { throw std::runtime_error{"permanent"}; }, + durable::with_retry_config{ + .name = "no-retry", + .retry = durable::retry_strategy::none(), + }); + }); + CHECK(exhausted.status == durable::invocation_status::failed); + CHECK(exhausted_service.checkpoint_calls == 2); + CHECK(exhausted_service.received_updates.back().action == + durable::operation_action::fail); +} + +void test_recursive_invoke_metadata_and_level() { + auto input = input_with(); + input.initial_state.operations.front().execution->input_payload = + R"({"n":4,"__recursive_level":2})"; + const durable::lambda_invocation_metadata metadata{ + .function_version = "7", + .invoked_function_arn = + "arn:aws:lambda:us-east-1:123456789012:function:recursive-worker", + .tenant_id = "tenant-9", + }; + + memory_service_client service; + const auto pending = durable::run( + input, service, metadata, [] { + return durable::recurse_json( + R"({"n":3})", + durable::recurse_config{ + .name = "recurse-left", + .with_recursive_level = true, + }) + .value_or("missing"); + }); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(service.checkpoint_calls == 1); + CHECK(service.received_updates.size() == 1); + const auto& update = service.received_updates[0]; + CHECK(update.type == durable::operation_type::chained_invoke); + CHECK(update.payload == + std::optional{ + R"({"n":3,"__recursive_level":3})"}); + CHECK(update.chained_invoke && + update.chained_invoke->function_name == + "arn:aws:lambda:us-east-1:123456789012:function:" + "recursive-worker:7"); + CHECK(update.chained_invoke && + update.chained_invoke->tenant_id == + std::optional{"tenant-9"}); + + durable::operation completed{ + .operation_id = first_operation_id(), + .type = durable::operation_type::chained_invoke, + .status = durable::operation_status::succeeded, + .name = "recurse-left", + .sub_type = + std::string{durable::operation_subtype::chained_invoke}, + .chained_invoke = durable::chained_invoke_details{ + .result = R"("done")", + }, + }; + memory_service_client replay_service; + const auto replayed = durable::run( + input_with({std::move(completed)}), replay_service, + durable::lambda_invocation_metadata{ + .invoked_function_arn = "worker:prod", + }, + [] { + return durable::recurse_json( + R"({"n":3})", + durable::recurse_config{ + .name = "recurse-left", + .function_name = "worker:prod", + }) + .value_or("missing"); + }); + CHECK(replayed.status == durable::invocation_status::succeeded); + CHECK(replayed.result == std::optional{R"("done")"}); + CHECK(replay_service.checkpoint_calls == 0); +} + +void test_recursive_invoke_validation() { + auto input = input_with(); + input.initial_state.operations.front().execution->input_payload = + R"({"n":4})"; + + memory_service_client same_payload_service; + const auto same_payload = durable::run( + input, same_payload_service, [] { + return durable::recurse_json( + R"({"n":4})", + durable::recurse_config{ + .function_name = "worker:prod", + }) + .value_or("missing"); + }); + CHECK(same_payload.status == durable::invocation_status::failed); + CHECK(same_payload_service.checkpoint_calls == 0); + + memory_service_client non_object_service; + const auto non_object = durable::run( + input, non_object_service, [] { + return durable::recurse_json( + R"("not-an-object")", + durable::recurse_config{ + .function_name = "worker:prod", + .with_recursive_level = true, + }) + .value_or("missing"); + }); + CHECK(non_object.status == durable::invocation_status::failed); + CHECK(non_object_service.checkpoint_calls == 0); + + memory_service_client missing_metadata_service; + const auto missing_metadata = durable::run( + input, missing_metadata_service, [] { + return durable::recurse_json(R"({"n":3})") + .value_or("missing"); + }); + CHECK(missing_metadata.status == durable::invocation_status::failed); + CHECK(missing_metadata_service.checkpoint_calls == 0); +} + +void test_flow_fanout_fanin_and_replay() { + std::atomic node_executions{0}; + std::atomic active_fanout{0}; + std::atomic max_fanout{0}; + + auto execute_definition = [&]() { + durable::flow_builder builder; + auto source = builder.node("source", [&] { + ++node_executions; + return durable::step( + [] { return 10; }, + durable::step_config{.name = "source-step"}); + }); + auto left = builder.node( + "left", + [&, source](durable::flow_node_context& context) { + ++node_executions; + const int current = + active_fanout.fetch_add(1, std::memory_order_acq_rel) + 1; + int observed = max_fanout.load(std::memory_order_relaxed); + while (observed < current && + !max_fanout.compare_exchange_weak( + observed, current, std::memory_order_relaxed)) { + } + std::this_thread::sleep_for(10ms); + active_fanout.fetch_sub(1, std::memory_order_acq_rel); + return context.outcome(source) + 1; + }); + auto right = builder.node( + "right", + [&, source](durable::flow_node_context& context) { + ++node_executions; + const int current = + active_fanout.fetch_add(1, std::memory_order_acq_rel) + 1; + int observed = max_fanout.load(std::memory_order_relaxed); + while (observed < current && + !max_fanout.compare_exchange_weak( + observed, current, std::memory_order_relaxed)) { + } + std::this_thread::sleep_for(10ms); + active_fanout.fetch_sub(1, std::memory_order_acq_rel); + return context.outcome(source) + 2; + }); + auto joined = builder.node( + "joined", + [&, left, right](durable::flow_node_context& context) { + ++node_executions; + return context.outcome(left) + context.outcome(right); + }); + builder.depends_on(left, source.succeeded()); + builder.depends_on(right, source.succeeded()); + builder.depends_on( + joined, left.succeeded() && right.succeeded()); + builder.outputs(joined.outcome()); + + auto result = durable::flow( + builder, + durable::flow_config{ + .name = "fanout-flow", + .max_concurrency = 2, + }); + CHECK(result.result(source).outcome == std::optional{10}); + CHECK(result.result(left).outcome == std::optional{11}); + CHECK(result.result(right).outcome == std::optional{12}); + return result.output(); + }; + + memory_service_client service; + const auto output = + durable::run(input_with(), service, execute_definition); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(output.result == std::optional{"23"}); + CHECK(node_executions.load(std::memory_order_relaxed) == 4); + CHECK(max_fanout.load(std::memory_order_relaxed) == 2); + + std::vector history; + history.reserve(service.operations.size()); + for (const auto& [id, operation] : service.operations) { + (void)id; + history.push_back(operation); + } + memory_service_client replay_service; + const auto replayed = durable::run( + input_with(std::move(history)), replay_service, + execute_definition); + CHECK(replayed.status == durable::invocation_status::succeeded); + CHECK(replayed.result == std::optional{"23"}); + CHECK(node_executions.load(std::memory_order_relaxed) == 4); + CHECK(replay_service.checkpoint_calls == 0); +} + +void test_flow_failure_routes_and_outputs() { + memory_service_client service; + const auto output = durable::run(input_with(), service, [] { + durable::flow_builder builder; + auto source = builder.node( + "source", []() -> int { + throw std::runtime_error{"source failed"}; + }); + auto success_path = builder.node( + "success-path", + [source](durable::flow_node_context& context) { + return context.outcome(source) + 1; + }); + auto recovery = builder.node( + "recovery", + [source](durable::flow_node_context& context) { + const auto error = context.error(source); + return error && error->message + ? std::string{"recovered: "} + *error->message + : std::string{"recovered"}; + }); + builder.depends_on(success_path, source.succeeded()); + builder.depends_on(recovery, source.failed()); + builder.outputs(recovery.outcome(), success_path.result()); + + auto result = durable::flow( + builder, durable::flow_config{.name = "recovery-flow"}); + CHECK(result.unhandled_failures().empty()); + CHECK(result.output_result(1).status == + durable::flow_node_status::skipped); + return result.output(0); + }); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK( + output.result == + std::optional{R"("recovered: source failed")"}); + + memory_service_client unhandled_service; + const auto caught = durable::run(input_with(), unhandled_service, [] { + durable::flow_builder builder; + auto source = builder.node( + "source", []() -> int { + throw std::runtime_error{"unhandled"}; + }); + builder.outputs(source.outcome()); + try { + (void)durable::flow( + builder, durable::flow_config{.name = "unhandled-flow"}); + } catch (const durable::flow_execution_error& error) { + CHECK(error.result().has_unhandled_failures()); + CHECK(error.result().has_unavailable_outputs()); + return std::string{"caught"}; + } + return std::string{"not-caught"}; + }); + CHECK(caught.status == durable::invocation_status::succeeded); + CHECK(caught.result == std::optional{R"("caught")"}); + CHECK(std::ranges::any_of( + unhandled_service.received_updates, [](const auto& update) { + return update.name == + std::optional{"unhandled-flow"} && + update.action == durable::operation_action::succeed; + })); +} + +void test_flow_validation_before_checkpoint() { + memory_service_client duplicate_service; + const auto duplicate = + durable::run(input_with(), duplicate_service, [] { + durable::flow_builder builder; + (void)builder.node("same", [] { return 1; }); + (void)builder.node("same", [] { return 2; }); + return 0; + }); + CHECK(duplicate.status == durable::invocation_status::failed); + CHECK(duplicate_service.checkpoint_calls == 0); + + memory_service_client cycle_service; + const auto cycle = durable::run(input_with(), cycle_service, [] { + durable::flow_builder builder; + auto first = builder.node("first", [] { return 1; }); + auto second = builder.node("second", [] { return 2; }); + builder.depends_on(first, second.succeeded()); + builder.depends_on(second, first.succeeded()); + builder.outputs(first.outcome()); + return durable::flow(builder).output(); + }); + CHECK(cycle.status == durable::invocation_status::failed); + CHECK(cycle.error && cycle.error->message && + cycle.error->message->find("cycle") != std::string::npos); + CHECK(cycle_service.checkpoint_calls == 0); +} + +void test_flow_any_and_output_pruning() { + std::atomic disconnected_executions{0}; + memory_service_client service; + const auto output = durable::run(input_with(), service, [&] { + durable::flow_builder builder; + auto failed = builder.node( + "failed", []() -> int { + throw std::runtime_error{"expected failure"}; + }); + auto succeeded = + builder.node("succeeded", [] { return 7; }); + auto recovered = builder.node( + "recovered", + [failed, succeeded](durable::flow_node_context& context) { + if (context.status("failed") == + durable::flow_node_status::failed) { + return 40; + } + return context.outcome(succeeded); + }); + auto disconnected = builder.node("disconnected", [&] { + ++disconnected_executions; + return 99; + }); + builder.depends_on( + recovered, failed.failed() || succeeded.succeeded()); + builder.outputs(recovered.outcome()); + + auto result = durable::flow( + builder, durable::flow_config{.name = "any-flow"}); + CHECK(result.result(disconnected).status == + durable::flow_node_status::skipped); + CHECK(result.unhandled_failures().empty()); + return result.output(); + }); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(output.result == std::optional{"40"}); + CHECK(disconnected_executions.load(std::memory_order_relaxed) == 0); + CHECK(std::ranges::none_of( + service.received_updates, [](const auto& update) { + return update.name == + std::optional{"disconnected"}; + })); +} + +void test_flow_partial_replay_after_suspension() { + std::atomic source_executions{0}; + std::atomic wait_completions{0}; + std::atomic join_executions{0}; + + auto execute_definition = [&]() { + durable::flow_builder builder; + auto source = builder.node("source", [&] { + ++source_executions; + return 1; + }); + auto waited = builder.node("waited", [&] { + durable::wait(1s, "node-wait"); + ++wait_completions; + return 2; + }); + auto joined = builder.node( + "joined", + [&, source, waited](durable::flow_node_context& context) { + ++join_executions; + return context.outcome(source) + context.outcome(waited); + }); + builder.depends_on( + joined, source.succeeded() && waited.succeeded()); + builder.outputs(joined.outcome()); + return durable::flow( + builder, + durable::flow_config{ + .name = "suspending-flow", + .max_concurrency = 2, + }) + .output(); + }; + + memory_service_client service; + const auto pending = + durable::run(input_with(), service, execute_definition); + CHECK(pending.status == durable::invocation_status::pending); + CHECK(source_executions.load(std::memory_order_relaxed) == 1); + CHECK(wait_completions.load(std::memory_order_relaxed) == 0); + CHECK(join_executions.load(std::memory_order_relaxed) == 0); + + std::vector history; + history.reserve(service.operations.size()); + for (auto& [id, operation] : service.operations) { + (void)id; + if (operation.type == durable::operation_type::wait) { + operation.status = durable::operation_status::succeeded; + } + history.push_back(operation); + } + memory_service_client replay_service; + for (const auto& operation : history) { + replay_service.operations.emplace( + operation.operation_id, operation); + } + const auto completed = durable::run( + input_with(std::move(history)), replay_service, + execute_definition); + CHECK(completed.status == durable::invocation_status::succeeded); + CHECK(completed.result == std::optional{"3"}); + CHECK(source_executions.load(std::memory_order_relaxed) == 1); + CHECK(wait_completions.load(std::memory_order_relaxed) == 1); + CHECK(join_executions.load(std::memory_order_relaxed) == 1); +} + +void test_flow_invalid_dependency_access_is_logical_failure() { + memory_service_client service; + const auto output = durable::run(input_with(), service, [] { + durable::flow_builder builder; + auto declared = builder.node("declared", [] { return 1; }); + auto unrelated = builder.node("unrelated", [] { return 2; }); + auto consumer = builder.node( + "consumer", + [unrelated](durable::flow_node_context& context) { + return context.outcome(unrelated); + }); + builder.depends_on(consumer, declared.succeeded()); + builder.outputs(consumer.result(), unrelated.outcome()); + + auto result = durable::flow( + builder, + durable::flow_config{.name = "invalid-access-flow"}); + CHECK(result.output_result(0).status == + durable::flow_node_status::failed); + return result.output(1); + }); + CHECK(output.status == durable::invocation_status::succeeded); + CHECK(output.result == std::optional{"2"}); +} + +void test_local_runner_wait_and_history() { + auto runner = durable::make_local_runner([] { + const int first = durable::step( + [] { return 20; }, + durable::step_config{.name = "first-step"}); + durable::wait(5s, "local-wait"); + return durable::step( + [first] { return first + 22; }, + durable::step_config{.name = "second-step"}); + }); + + const auto result = runner.run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.deserialize_result() == 42); + CHECK(result.invocation_count() == 2); + CHECK( + std::chrono::duration_cast( + result.virtual_time().time_since_epoch()) + .count() == 5); + CHECK(result.step("first-step") && + result.step("first-step")->status == + durable::operation_status::succeeded); + CHECK(result.wait("local-wait") && + result.wait("local-wait")->status == + durable::operation_status::succeeded); + CHECK(result.step("second-step") != nullptr); +} + +void test_local_runner_polling_and_parallel() { + auto runner = durable::make_local_runner([] { + const int polled = durable::wait_for_condition( + [](const std::optional& state) { + return state.value_or(0) + 1; + }, + 0, + [](const int& value, std::uint32_t) { + return value >= 3 + ? std::optional{} + : std::optional{1s}; + }, + durable::wait_for_condition_config{.name = "local-poll"}); + + const std::array branches{ + std::function{[polled] { return polled + 1; }}, + std::function{[polled] { return polled + 2; }}}; + const auto parallel = durable::parallel( + branches, + durable::parallel_config{ + .name = "local-parallel", + .max_concurrency = 2, + }); + const auto values = parallel.results(); + return std::accumulate(values.begin(), values.end(), 0); + }); + + const auto result = runner.run(); + CHECK(result.status() == durable::local_run_status::succeeded); + CHECK(result.deserialize_result() == 9); + CHECK(result.invocation_count() == 3); + CHECK( + std::chrono::duration_cast( + result.virtual_time().time_since_epoch()) + .count() == 2); + CHECK(result.step("local-poll") && + result.step("local-poll")->step && + result.step("local-poll")->step->attempt == 3); +} + +void test_local_runner_callback_pause_resume_and_timeout() { + std::string submitted_callback; + auto runner = durable::make_local_runner([&] { + return durable::wait_for_callback( + [&](std::string_view callback_id) { + submitted_callback = callback_id; + }, + durable::wait_for_callback_config{ + .name = "local-approval", + .timeout = 30s, + }) + .value_or("missing"); + }); + + const auto pending = runner.run(); + CHECK( + pending.status() == + durable::local_run_status::pending_external); + CHECK(!submitted_callback.empty()); + CHECK(pending.pending_callback_ids().size() == 1); + runner.send_callback_success( + submitted_callback, std::string{"approved"}); + const auto completed = runner.resume(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.deserialize_result() == "approved"); + CHECK(completed.invocation_count() == 2); + + auto timeout_runner = durable::make_local_runner( + [] { + auto callback = durable::create_callback( + durable::callback_config{ + .name = "timeout-callback", + .timeout = 5s, + }); + return callback.result().value_or("missing"); + }, + durable::local_runner_options{ + .auto_advance_callback_timeouts = true, + }); + const auto timed_out = timeout_runner.run(); + CHECK(timed_out.status() == durable::local_run_status::failed); + CHECK( + std::chrono::duration_cast( + timed_out.virtual_time().time_since_epoch()) + .count() == 5); + CHECK(timed_out.operation_by_name("timeout-callback") && + timed_out.operation_by_name("timeout-callback")->status == + durable::operation_status::timed_out); +} + +void test_local_runner_mocked_invoke_and_deadlock() { + auto runner = durable::make_local_runner([] { + return durable::invoke( + "worker:prod", std::string{"input"}, + durable::invoke_config{.name = "local-invoke"}) + .value_or("missing"); + }); + runner.mock_invoke_success("worker:prod", R"("done")"); + const auto completed = runner.run(); + CHECK(completed.status() == durable::local_run_status::succeeded); + CHECK(completed.deserialize_result() == "done"); + CHECK(completed.invocation_count() == 2); + + auto blocked_runner = durable::make_local_runner([] { + return durable::invoke( + "unmocked:prod", std::string{"input"}, + durable::invoke_config{.name = "blocked-invoke"}) + .value_or("missing"); + }); + const auto blocked = blocked_runner.run(); + CHECK( + blocked.status() == + durable::local_run_status::pending_external); + CHECK(blocked.pending_reason().find("unmocked") != std::string::npos); + + durable::local_runner retrying_runner{ + [](const durable::invocation_input&, durable::service_client&, + const durable::lambda_invocation_metadata&) { + return durable::invocation_output{ + .status = durable::invocation_status::retry, + }; + }, + durable::local_runner_options{.max_invocations = 3}}; + const auto limited = retrying_runner.run(); + CHECK( + limited.status() == + durable::local_run_status::invocation_limit_exceeded); + CHECK(limited.invocation_count() == 3); +} + +void test_local_runner_timeout_heartbeat_and_mock_failure() { + auto timeout_runner = durable::make_local_runner( + [] { + durable::wait(10s, "too-long"); + return 1; + }, + durable::local_runner_options{ + .execution_timeout = 3s, + }); + const auto timed_out = timeout_runner.run(); + CHECK(timed_out.status() == durable::local_run_status::timed_out); + CHECK(timed_out.invocation_count() == 1); + CHECK( + std::chrono::duration_cast( + timed_out.virtual_time().time_since_epoch()) + .count() == 3); + + std::string callback_id; + auto heartbeat_runner = durable::make_local_runner([&] { + auto callback = durable::create_callback( + durable::callback_config{ + .name = "heartbeat-callback", + .heartbeat_timeout = 2s, + }); + callback_id = callback.callback_id(); + return callback.result().value_or("missing"); + }); + CHECK( + heartbeat_runner.run().status() == + durable::local_run_status::pending_external); + heartbeat_runner.advance_time(1s); + heartbeat_runner.send_callback_heartbeat(callback_id); + heartbeat_runner.advance_time(1s); + CHECK( + heartbeat_runner.resume().status() == + durable::local_run_status::pending_external); + heartbeat_runner.advance_time(2s); + const auto heartbeat_timeout = heartbeat_runner.resume(); + CHECK( + heartbeat_timeout.status() == + durable::local_run_status::failed); + CHECK( + heartbeat_timeout.operation_by_name("heartbeat-callback") && + heartbeat_timeout.operation_by_name("heartbeat-callback")->status == + durable::operation_status::timed_out); + + auto failure_runner = durable::make_local_runner([] { + return durable::invoke( + "failing-worker:prod", std::string{"input"}, + durable::invoke_config{.name = "failing-invoke"}) + .value_or("missing"); + }); + failure_runner.mock_invoke_failure( + "failing-worker:prod", + durable::error_object{ + .message = "mock failure", + .type = "MockInvokeError", + }); + const auto invoke_failure = failure_runner.run(); + CHECK(invoke_failure.status() == durable::local_run_status::failed); + CHECK(invoke_failure.output().error && + invoke_failure.output().error->message == "mock failure"); + + durable::local_runner deadlocked_runner{ + [](const durable::invocation_input&, durable::service_client&, + const durable::lambda_invocation_metadata&) { + return durable::invocation_output{ + .status = durable::invocation_status::pending, + }; + }}; + const auto deadlocked = deadlocked_runner.run(); + CHECK(deadlocked.status() == durable::local_run_status::deadlocked); +} + +void test_instrumentation_plugin_lifecycle() { + auto faulty = std::make_shared(true); + auto healthy = std::make_shared(); + auto retry_runner = durable::make_local_runner( + [] { + return durable::step( + [](std::uint32_t attempt) -> std::string { + if (attempt == 1U) { + throw std::runtime_error{"retry once"}; + } + return "ok"; + }, + durable::step_config{ + .name = "flaky", + .retry = durable::retry_strategy{ + .max_attempts = 2, + .initial_delay = 1s, + .max_delay = 1s, + .backoff_rate = 1.0, + .jitter = durable::jitter_strategy::none, + }, + }); + }, + durable::local_runner_options{}, + durable::default_serdes{}, + durable::run_options{.plugins = {faulty, healthy}}); + const auto retried = retry_runner.run(); + CHECK(retried.status() == durable::local_run_status::succeeded); + CHECK(healthy->invocation_first == std::vector({true, false})); + CHECK( + healthy->invocation_end_first == + std::vector({true, false})); + CHECK( + healthy->invocation_statuses == + std::vector({"PENDING", "SUCCEEDED"})); + CHECK(healthy->request_ids.size() == 2); + CHECK(!healthy->request_ids[0].empty()); + CHECK(healthy->request_ids[0] != healthy->request_ids[1]); + CHECK(healthy->updated_counts == std::vector({0U, 1U})); + CHECK(healthy->operation_starts.size() == 2); + CHECK(!healthy->operation_starts[0].is_replay); + CHECK(healthy->operation_starts[1].is_replay); + CHECK(healthy->attempt_starts.size() == 2); + CHECK(healthy->attempt_starts[0].attempt == 1U); + CHECK(healthy->attempt_starts[1].attempt == 2U); + CHECK(healthy->attempt_ends.size() == 2); + CHECK(healthy->attempt_ends[0].succeeded == false); + CHECK(healthy->attempt_ends[1].succeeded == true); + CHECK(healthy->operation_ends.size() == 1); + CHECK(healthy->operation_ends[0].status == "SUCCEEDED"); + CHECK(healthy->operation_ends[0].result == R"("ok")"); + CHECK(healthy->operation_ends[0].attempt == 2U); + CHECK(healthy->operation_ends[0].has_start_timestamp); + CHECK(healthy->operation_ends[0].has_end_timestamp); + CHECK(!healthy->change_updated_counts.empty()); + CHECK( + faulty->invocation_first.size() == + healthy->invocation_first.size()); + + auto wait_plugin = std::make_shared(); + auto wait_runner = durable::make_local_runner( + [] { + durable::wait(2s, "timer"); + return std::string{"done"}; + }, + durable::local_runner_options{}, + durable::default_serdes{}, + durable::run_options{.plugins = {wait_plugin}}); + const auto waited = wait_runner.run(); + CHECK(waited.status() == durable::local_run_status::succeeded); + CHECK( + wait_plugin->updated_counts == + std::vector({0U, 1U})); + const auto wait_end = std::ranges::find_if( + wait_plugin->operation_ends, + [](const recording_plugin::operation_record& value) { + return value.type == "WAIT"; + }); + CHECK(wait_end != wait_plugin->operation_ends.end()); + if (wait_end != wait_plugin->operation_ends.end()) { + CHECK(wait_end->status == "SUCCEEDED"); + CHECK(!wait_end->is_replay); + } + + auto child_plugin = std::make_shared(); + auto child_runner = durable::make_local_runner( + [] { + return durable::run_in_child_context( + [] { + (void)durable::step([] { return 1; }); + durable::wait(1s); + return std::string{"child-done"}; + }, + durable::child_context_config{.name = "child"}); + }, + durable::local_runner_options{}, + durable::default_serdes{}, + durable::run_options{.plugins = {child_plugin}}); + const auto child = child_runner.run(); + CHECK(child.status() == durable::local_run_status::succeeded); + std::vector child_attempts; + for (const auto& attempt : child_plugin->attempt_starts) { + const auto operation = std::ranges::find_if( + child_plugin->operation_starts, + [&](const recording_plugin::operation_record& value) { + return value.id == attempt.id && + value.type == "CONTEXT"; + }); + if (operation != child_plugin->operation_starts.end()) { + child_attempts.push_back(attempt); + } + } + CHECK(child_attempts.size() == 2); + if (child_attempts.size() == 2) { + CHECK(!child_attempts[0].is_replaying_children); + CHECK(child_attempts[1].is_replaying_children); + } +} + +void test_extension_operation_spi() { + int invocations = 0; + auto reservation_runner = durable::make_local_runner([&] { + ++invocations; + auto extension = durable::get_extension_context(); + auto left = extension.reserve("left"); + auto right = extension.reserve("right"); + auto pause = extension.reserve("pause"); + + std::string left_value; + std::string right_value; + if ((invocations % 2) == 1) { + right_value = right.step( + [] { return std::string{"R"}; }, "AcmeRight"); + left_value = left.step( + [] { return std::string{"L"}; }, "AcmeLeft"); + } else { + left_value = left.step( + [] { return std::string{"L"}; }, "AcmeLeft"); + right_value = right.step( + [] { return std::string{"R"}; }, "AcmeRight"); + } + pause.wait(1s, "AcmePause"); + return left_value + right_value; + }); + const auto reserved = reservation_runner.run(); + CHECK(reserved.status() == durable::local_run_status::succeeded); + CHECK(reserved.deserialize_result() == "LR"); + CHECK(invocations == 2); + durable::operation_id_generator sequential_ids; + const auto left_id = sequential_ids.next(); + const auto right_id = sequential_ids.next(); + const auto pause_id = sequential_ids.next(); + const auto* left = reserved.operation_by_name("left"); + const auto* right = reserved.operation_by_name("right"); + const auto* pause = reserved.operation_by_name("pause"); + CHECK(left && left->operation_id == left_id); + CHECK(left && left->sub_type == "AcmeLeft"); + CHECK(right && right->operation_id == right_id); + CHECK(right && right->sub_type == "AcmeRight"); + CHECK(pause && pause->operation_id == pause_id); + CHECK(pause && pause->sub_type == "AcmePause"); + + auto stateful_runner = durable::make_local_runner([] { + return durable::get_extension_context() + .reserve( + "poll", std::string_view{"poll-node"}) + .stateful_step( + [](const std::optional& state) { + const int value = state.value_or(0); + return value < 2 + ? durable::extension_step_result::retry( + value + 1, 1s) + : durable::extension_step_result::succeed( + value); + }, + "AcmePoll", 0); + }); + const auto stateful = stateful_runner.run(); + CHECK(stateful.status() == durable::local_run_status::succeeded); + CHECK(stateful.deserialize_result() == 2); + const auto* poll = stateful.operation_by_name("poll"); + CHECK(poll && poll->sub_type == "AcmePoll"); + durable::operation_id_generator local_ids; + CHECK( + poll && + poll->operation_id == local_ids.reserve("poll-node")); + + auto exception_retry_runner = durable::make_local_runner([] { + return durable::get_extension_context() + .reserve("retry") + .stateful_step( + [](const std::optional& state) { + if (state == std::optional{"initial"}) { + throw std::runtime_error{"retry"}; + } + return durable::extension_step_result::succeed( + state.value_or("missing")); + }, + "AcmeRetry", std::string{"initial"}, + [](const std::exception&, + const std::optional&, + std::uint32_t) { + return std::optional{ + durable::extension_step_result::retry( + "retried", 1s)}; + }); + }); + const auto exception_retry = exception_retry_runner.run(); + CHECK( + exception_retry.status() == + durable::local_run_status::succeeded); + CHECK( + exception_retry.deserialize_result() == + "retried"); + + auto child_runner = durable::make_local_runner([] { + return durable::get_extension_context() + .reserve( + "child", std::string_view{"child-node"}) + .run_in_child_context( + [] { + return durable::get_extension_context() + .reserve( + "nested", std::string_view{"nested-node"}) + .step( + [] { return std::string{"nested"}; }, + "AcmeNestedStep"); + }, + "AcmeContext"); + }); + const auto child = child_runner.run(); + CHECK(child.status() == durable::local_run_status::succeeded); + CHECK(child.deserialize_result() == "nested"); + const auto* child_operation = child.operation_by_name("child"); + const auto* nested_operation = child.operation_by_name("nested"); + CHECK(child_operation && child_operation->sub_type == "AcmeContext"); + CHECK( + nested_operation && + nested_operation->sub_type == "AcmeNestedStep"); + CHECK( + child_operation && nested_operation && + nested_operation->parent_id == + std::optional{ + child_operation->operation_id}); + + auto invoke_runner = durable::make_local_runner([] { + return durable::get_extension_context() + .reserve("custom-invoke") + .invoke( + "worker:prod", std::string{"payload"}, "AcmeInvoke") + .value_or("missing"); + }); + invoke_runner.mock_invoke_success("worker:prod", R"("done")"); + const auto invoked = invoke_runner.run(); + CHECK(invoked.status() == durable::local_run_status::succeeded); + CHECK(invoked.deserialize_result() == "done"); + CHECK( + invoked.operation_by_name("custom-invoke") && + invoked.operation_by_name("custom-invoke")->sub_type == + "AcmeInvoke"); + + auto callback_runner = durable::make_local_runner([] { + auto callback = durable::get_extension_context() + .reserve("custom-callback") + .create_callback( + "AcmeCallback", {}, + durable::default_serdes{}); + return callback.result().value_or("null"); + }); + auto pending_callback = callback_runner.run(); + CHECK( + pending_callback.status() == + durable::local_run_status::pending_external); + callback_runner.send_callback_success( + pending_callback.pending_callback_ids().front(), + R"("callback-result")"); + const auto callback = callback_runner.resume(); + CHECK(callback.status() == durable::local_run_status::succeeded); + CHECK(callback.output().result == R"("callback-result")"); + CHECK( + callback.operation_by_name("custom-callback") && + callback.operation_by_name("custom-callback")->sub_type == + "AcmeCallback"); + + auto one_shot_runner = durable::make_local_runner([] { + auto reservation = + durable::get_extension_context().reserve("one-shot"); + const auto value = reservation.step( + [] { return std::string{"done"}; }, "AcmeStep"); + try { + reservation.wait(1s, "AcmeWait"); + return std::string{"unexpected"}; + } catch (const durable::invalid_state_error&) { + return value; + } + }); + CHECK( + one_shot_runner.run().status() == + durable::local_run_status::succeeded); + + auto reserved_subtype_runner = durable::make_local_runner([] { + auto reservation = + durable::get_extension_context().reserve("reserved"); + try { + return reservation.step( + [] { return std::string{"bad"}; }, + durable::operation_subtype::step); + } catch (const durable::durable_error&) { + return std::string{"rejected"}; + } + }); + const auto reserved_subtype = reserved_subtype_runner.run(); + CHECK( + reserved_subtype.status() == + durable::local_run_status::succeeded); + CHECK( + reserved_subtype.deserialize_result() == + "rejected"); +} + +} // namespace + +int main() { + test_operation_id_golden_vectors(); + test_default_serdes(); + test_forward_compatible_wire_enums(); + test_invocation_wire_codec(); + test_python_cross_language_fixtures(); + test_invocation_output_wire_codec(); + test_wire_codec_validation(); + test_json_runtime_adapter(); + test_step_success_and_replay(); + test_wait_suspends_and_replays(); + test_retry_and_transport_failures(); + test_step_attempt_and_retry_filter(); + test_at_most_once_interruption(); + test_unknown_future_status_fails_closed(); + test_unknown_root_status_fails_closed(); + test_callback_creation_and_replay(); + test_callback_failure_is_deferred_to_result(); + test_chained_invoke_start_and_replay(); + test_chained_invoke_failure(); + test_child_context_lifecycle_and_replay(); + test_child_context_suspension_and_failure(); + test_child_context_replays_prefixed_history(); + test_child_context_replay_children_and_virtual_mode(); + test_wait_for_callback_composition(); + test_concurrent_checkpoint_batching(); + test_checkpoint_batch_limits(); + test_parallel_execution_and_replay(); + test_parallel_failure_and_suspension(); + test_durable_map_completion_threshold(); + test_parallel_void_and_flat_nesting(); + test_parallel_percentage_accessors_and_concurrency_validation(); + test_map_item_index_and_naming(); + test_replay_safe_values(); + test_wait_for_condition_stateful_polling(); + test_wait_for_condition_pending_and_exhaustion(); + test_with_retry_scope(); + test_recursive_invoke_metadata_and_level(); + test_recursive_invoke_validation(); + test_flow_fanout_fanin_and_replay(); + test_flow_failure_routes_and_outputs(); + test_flow_validation_before_checkpoint(); + test_flow_any_and_output_pruning(); + test_flow_partial_replay_after_suspension(); + test_flow_invalid_dependency_access_is_logical_failure(); + test_local_runner_wait_and_history(); + test_local_runner_polling_and_parallel(); + test_local_runner_callback_pause_resume_and_timeout(); + test_local_runner_mocked_invoke_and_deadlock(); + test_local_runner_timeout_heartbeat_and_mock_failure(); + test_instrumentation_plugin_lifecycle(); + test_extension_operation_spi(); + + if (failures != 0) { + std::cerr << failures << " test assertion(s) failed\n"; + return 1; + } + std::cout << "All durable execution tests passed\n"; + return 0; +}